mirror of
https://github.com/doolijb/serene-pub.git
synced 2026-07-09 15:58:23 +00:00
Fix edit chat form, add dist docker build
This commit is contained in:
parent
df857c6eb8
commit
6e0ff59f3f
10 changed files with 587 additions and 29 deletions
27
.dockerignore
Normal file
27
.dockerignore
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Dependencies and build output (rebuilt inside container)
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
.svelte-kit
|
||||
|
||||
# Git and CI
|
||||
.git
|
||||
.github
|
||||
.claude
|
||||
|
||||
# Dev/project-local data
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
AI-TEMP
|
||||
char-cards-repo-exploration
|
||||
android
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS artifacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
88
.github/workflows/docker.yml
vendored
Normal file
88
.github/workflows/docker.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
- "v*.*.*-dev"
|
||||
- "v*.*.*-alpha"
|
||||
- "v*.*.*-beta"
|
||||
- "v*.*.*-pr-*"
|
||||
- "v*.*.*-rc-*"
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Determine whether this tag is a stable release.
|
||||
# Stable = vX.Y.Z with NO suffix (no -dev, -alpha, etc.)
|
||||
# Only stable releases receive the `latest` tag and the
|
||||
# short major/minor convenience aliases (e.g. "1", "1.2").
|
||||
# -------------------------------------------------------
|
||||
- name: Classify release
|
||||
id: classify
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "stable=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "stable=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Tag: $TAG → stable=$( [[ $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo true || echo false )"
|
||||
|
||||
- name: Set up QEMU (multi-platform support)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Tag strategy:
|
||||
#
|
||||
# Stable (v1.2.3) → 1.2.3, 1.2, 1, latest
|
||||
# Pre-release (v1.2.3-beta) → 1.2.3-beta only
|
||||
# Pre-release (v1.2.3-rc-1) → 1.2.3-rc-1 only
|
||||
# Pre-release (v1.2.3-pr-5) → 1.2.3-pr-5 only
|
||||
#
|
||||
# The major (1) and minor (1.2) aliases are omitted for
|
||||
# pre-releases so that "latest", "1", and "1.2" always
|
||||
# resolve to a stable image.
|
||||
# -------------------------------------------------------
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
flavor: |
|
||||
latest=${{ steps.classify.outputs.stable }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.classify.outputs.stable }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.classify.outputs.stable }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Layer cache via GitHub Actions cache (speeds up repeat builds)
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
206
DOCKER.md
Normal file
206
DOCKER.md
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# Running Serene Pub with Docker
|
||||
|
||||
Official images are published to the GitHub Container Registry at:
|
||||
|
||||
```
|
||||
ghcr.io/doolijb/serene-pub
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dist.yml up -d
|
||||
```
|
||||
|
||||
That's it. The web UI will be available at **http://localhost:3000**.
|
||||
|
||||
The first startup runs database migrations automatically and creates an admin account on first login.
|
||||
|
||||
---
|
||||
|
||||
## Image tags
|
||||
|
||||
| Tag | What you get |
|
||||
|-----|--------------|
|
||||
| `latest` | Latest **stable** release |
|
||||
| `1`, `1.2` | Latest stable within that major / minor line |
|
||||
| `1.2.3` | Exact stable version |
|
||||
| `1.2.3-beta` | Pre-release — beta |
|
||||
| `1.2.3-rc-1` | Pre-release — release candidate |
|
||||
| `1.2.3-pr-5` | Pre-release — pull-request build |
|
||||
|
||||
**`latest`, major, and minor aliases are only updated on stable releases.**
|
||||
Pre-release tags are published but never assigned to `latest`, so pinning to `latest` will not pull an unstable build.
|
||||
|
||||
To pin to an exact version (recommended for production):
|
||||
|
||||
```yaml
|
||||
image: ghcr.io/doolijb/serene-pub:1.2.3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dist.yml pull
|
||||
docker compose -f docker-compose.dist.yml up -d
|
||||
```
|
||||
|
||||
Database migrations run automatically on startup. Back up your data volume before upgrading across major versions.
|
||||
|
||||
---
|
||||
|
||||
## Persistent data
|
||||
|
||||
Everything that needs to survive container restarts lives under `SERENE_PUB_DATA_DIR` (default `/data` inside the container). The `docker-compose.dist.yml` mounts this as a named volume called `serene-pub-data`.
|
||||
|
||||
The data directory contains:
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `data/serene-pub.db` | PGLite database (characters, chats, lorebooks, settings…) |
|
||||
| `transformers-cache/` | Downloaded AI embedding models |
|
||||
| `koboldcpp/models/` | Default KoboldCPP model directory (managed mode) |
|
||||
|
||||
### Bind-mount instead of a named volume
|
||||
|
||||
If you prefer a host directory (e.g. for easy backups):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./serene-pub-data:/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
All variables are optional unless noted.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SERENE_PUB_DATA_DIR` | `/data` | Directory for all persistent data |
|
||||
| `PORT` | `3000` | HTTP port the web server listens on |
|
||||
| `SOCKETS_PORT` | `3001` | WebSocket server port |
|
||||
| `SERENE_AUTO_OPEN` | `1` | Set to `1` to disable automatic browser launch (always disabled in containers) |
|
||||
| `NODE_ENV` | `production` | Node.js environment |
|
||||
| `USER_TOKEN_EXPIRATION_HOURS` | `168` | Session lifetime in hours (168 = 7 days) |
|
||||
| `TRANSFORMERS_CACHE` | `$SERENE_PUB_DATA_DIR/transformers-cache` | Override embedding model cache directory |
|
||||
|
||||
---
|
||||
|
||||
## Changing ports
|
||||
|
||||
Update both the `ports` mapping **and** the corresponding environment variable:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:8080" # host:container
|
||||
- "8081:8081"
|
||||
environment:
|
||||
PORT: 8080
|
||||
SOCKETS_PORT: 8081
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running behind a reverse proxy
|
||||
|
||||
Serene Pub uses WebSockets for real-time updates. Your proxy must forward WebSocket upgrade requests.
|
||||
|
||||
### Nginx example
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name serene.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both ports (HTTP and WebSocket) should be proxied, or configure Serene Pub to use the same port for both by pointing `SOCKETS_PORT` to the same value as `PORT` if your reverse proxy consolidates them.
|
||||
|
||||
---
|
||||
|
||||
## AI connections
|
||||
|
||||
### Ollama
|
||||
|
||||
Run Ollama in a separate container and point Serene Pub at it:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
serene-pub:
|
||||
image: ghcr.io/doolijb/serene-pub:latest
|
||||
environment:
|
||||
# Serene Pub connects to Ollama via its container name
|
||||
depends_on:
|
||||
- ollama
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
|
||||
volumes:
|
||||
serene-pub-data:
|
||||
ollama-data:
|
||||
```
|
||||
|
||||
In Serene Pub's connection settings, set the Ollama base URL to `http://ollama:11434`.
|
||||
|
||||
### KoboldCPP — external mode
|
||||
|
||||
Run KoboldCPP as a separate container (or on the host) and add a KoboldCPP connection in Serene Pub pointing to its URL. No extra Docker configuration needed.
|
||||
|
||||
### KoboldCPP — managed mode
|
||||
|
||||
Managed mode lets Serene Pub spawn and control the KoboldCPP process directly. Inside a container this requires:
|
||||
|
||||
1. Mounting the KoboldCPP binary into the container.
|
||||
2. Mounting your model files.
|
||||
3. Setting the binary directory in Serene Pub's KoboldCPP settings (or via environment at startup).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
serene-pub:
|
||||
image: ghcr.io/doolijb/serene-pub:latest
|
||||
volumes:
|
||||
- serene-pub-data:/data
|
||||
- /path/to/koboldcpp:/koboldcpp:ro # binary directory
|
||||
- /path/to/models:/data/koboldcpp/models # model files
|
||||
```
|
||||
|
||||
After mounting, configure the binary path in **Settings → KoboldCPP Manager**.
|
||||
|
||||
> **Note:** Managed KoboldCPP mode requires the Linux binary to be executable and compatible with the container's architecture (amd64 or arm64).
|
||||
|
||||
---
|
||||
|
||||
## Building locally
|
||||
|
||||
```bash
|
||||
docker build -t serene-pub:local .
|
||||
docker run -p 3000:3000 -p 3001:3001 -v serene-pub-data:/data serene-pub:local
|
||||
```
|
||||
|
||||
Multi-platform build (requires `docker buildx`):
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t serene-pub:local \
|
||||
--load \
|
||||
.
|
||||
```
|
||||
45
Dockerfile
Normal file
45
Dockerfile
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# ============================================================
|
||||
# Stage 1 — Build
|
||||
# ============================================================
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps first (layer-cached until package files change)
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Prune to production-only node_modules
|
||||
RUN npm prune --production
|
||||
|
||||
# ============================================================
|
||||
# Stage 2 — Runtime
|
||||
# ============================================================
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Defaults — all overridable at runtime via environment variables
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
SOCKETS_PORT=3001 \
|
||||
SERENE_PUB_DATA_DIR=/data \
|
||||
# Disable auto-open in container environments
|
||||
SERENE_AUTO_OPEN=1
|
||||
|
||||
# Copy only what's needed to run
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Persistent data volume (database, uploads, model cache, etc.)
|
||||
VOLUME ["/data"]
|
||||
|
||||
EXPOSE 3000 3001
|
||||
|
||||
CMD ["node", "build/index.js"]
|
||||
31
README.md
31
README.md
|
|
@ -152,34 +152,31 @@ Linux, MacOS and Windows are supported!
|
|||
|
||||
## 🐳 Docker
|
||||
|
||||
You can run Serene Pub with Docker. The repository includes a production-ready `Dockerfile` and a `docker-compose.yml` that mounts a persistent named volume for the app data (database).
|
||||
Pre-built images are published to the GitHub Container Registry on every release:
|
||||
|
||||
- Build the image locally:
|
||||
|
||||
```bash
|
||||
docker build -t serene-pub .
|
||||
```
|
||||
ghcr.io/doolijb/serene-pub:latest ← always the latest stable release
|
||||
ghcr.io/doolijb/serene-pub:1.2.3 ← exact version pin
|
||||
```
|
||||
|
||||
- Run the container (host port 3002 -> container port 3000):
|
||||
**Quickstart** — download [`docker-compose.dist.yml`](docker-compose.dist.yml) from the release assets, then:
|
||||
|
||||
```bash
|
||||
docker run -p 3002:3000 --env PORT=3000 --rm serene-pub
|
||||
docker compose -f docker-compose.dist.yml up -d
|
||||
```
|
||||
|
||||
- Run with a host bind-mount for data persistence (creates `./data` on host):
|
||||
The web UI will be at **http://localhost:3000**.
|
||||
|
||||
**Data directory** — all persistent data (database, model cache, uploads) is stored under a single directory controlled by the `SERENE_PUB_DATA_DIR` environment variable. The default inside the container is `/data`, which is mounted as a named Docker volume. To use a host path instead:
|
||||
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
docker run -p 3002:3000 -v "$(pwd)/data":/root/.local/share/SerenePub/data --env PORT=3000 --rm serene-pub
|
||||
docker run -p 3000:3000 -p 3001:3001 \
|
||||
-e SERENE_PUB_DATA_DIR=/data \
|
||||
-v "$(pwd)/serene-pub-data":/data \
|
||||
ghcr.io/doolijb/serene-pub:latest
|
||||
```
|
||||
|
||||
- Use `docker-compose` (named volume `serenepub_data` will be created):
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
The app stores its PGlite database under `/root/.local/share/SerenePub/data` inside the container; mounting that path to a host directory or named volume keeps your chats and settings between restarts.
|
||||
See [DOCKER.md](DOCKER.md) for full documentation — ports, volumes, reverse proxy setup, Ollama/KoboldCPP integration, and more.
|
||||
|
||||
**Need help?** Check out our **[Setup Guide](https://github.com/doolijb/serene-pub/wiki/Installation-&-Setup)** in the wiki.
|
||||
|
||||
|
|
|
|||
38
docker-compose.dist.yml
Normal file
38
docker-compose.dist.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
services:
|
||||
serene-pub:
|
||||
image: ghcr.io/doolijb/serene-pub:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000" # Web UI
|
||||
- "3001:3001" # WebSocket server
|
||||
volumes:
|
||||
- serene-pub-data:/data
|
||||
environment:
|
||||
# Data directory inside the container (matches the volume mount above)
|
||||
SERENE_PUB_DATA_DIR: /data
|
||||
|
||||
# HTTP port the web UI listens on (must match the left side of the ports mapping)
|
||||
PORT: 3000
|
||||
|
||||
# WebSocket server port (must match the left side of the ports mapping)
|
||||
SOCKETS_PORT: 3001
|
||||
|
||||
# Disable automatic browser launch (always disabled in containers)
|
||||
SERENE_AUTO_OPEN: 1
|
||||
|
||||
# --- Optional overrides ---
|
||||
|
||||
# Session token lifetime in hours (default: 168 = 7 days)
|
||||
# USER_TOKEN_EXPIRATION_HOURS: 168
|
||||
|
||||
# Override the directory where AI model caches (transformers) are stored.
|
||||
# Defaults to $SERENE_PUB_DATA_DIR/transformers-cache, already inside the volume.
|
||||
# TRANSFORMERS_CACHE: /data/transformers-cache
|
||||
|
||||
# KoboldCPP managed-mode binary directory (only needed for managed KoboldCPP).
|
||||
# Mount your KoboldCPP binary into the container and set this path.
|
||||
# See DOCKER.md for details.
|
||||
# KOBOLDCPP_BINARY_DIR: /koboldcpp
|
||||
|
||||
volumes:
|
||||
serene-pub-data:
|
||||
|
|
@ -134,6 +134,30 @@
|
|||
hasChanges = isDirty
|
||||
})
|
||||
|
||||
// Initialize data for new chat creation
|
||||
$effect(() => {
|
||||
if (showEditChatForm && !editChatId && !data) {
|
||||
data = {
|
||||
chat: {
|
||||
id: undefined,
|
||||
name: "",
|
||||
scenario: "",
|
||||
groupReplyStrategy: "ordered",
|
||||
lorebookId: null,
|
||||
tags: [],
|
||||
connectionId: null,
|
||||
samplingConfigId: null,
|
||||
promptConfigId: null
|
||||
},
|
||||
characterIds: [],
|
||||
personaIds: [],
|
||||
guestIds: [],
|
||||
characterPositions: {}
|
||||
}
|
||||
originalData = JSON.parse(JSON.stringify(data))
|
||||
}
|
||||
})
|
||||
|
||||
// SELECTED CHARACTERS AND PERSONAS
|
||||
let selectedCharacters: SelectCharacter[] = $state([])
|
||||
let selectedPersonas: SelectPersona[] = $state([])
|
||||
|
|
@ -234,21 +258,54 @@
|
|||
if (!selectedCharacters.some((c) => c.id === char.id))
|
||||
selectedCharacters = [...selectedCharacters, char]
|
||||
showCharacterModal = false
|
||||
// Update data to reflect the new character
|
||||
if (data) {
|
||||
data = {
|
||||
...data,
|
||||
characterIds: selectedCharacters.map((c) => c.id),
|
||||
characterPositions: Object.fromEntries(
|
||||
selectedCharacters.map((cc, i) => [cc.id, i])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveCharacter(id: number) {
|
||||
selectedCharacters = selectedCharacters.filter((c) => c.id !== id)
|
||||
// Update data to reflect removed character
|
||||
if (data) {
|
||||
data = {
|
||||
...data,
|
||||
characterIds: selectedCharacters.map((c) => c.id),
|
||||
characterPositions: Object.fromEntries(
|
||||
selectedCharacters.map((cc, i) => [cc.id, i])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddPersona(p: SelectPersona & { id: number }) {
|
||||
if (!selectedPersonas.some((pp) => pp.id === p.id))
|
||||
selectedPersonas = [...selectedPersonas, p]
|
||||
showPersonaModal = false
|
||||
// Sync data.personaIds
|
||||
// Update data to reflect the new persona
|
||||
if (data) {
|
||||
data = {
|
||||
...data,
|
||||
personaIds: selectedPersonas.map((p) => p.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemovePersona(id: number) {
|
||||
selectedPersonas = selectedPersonas.filter((p) => p.id !== id)
|
||||
// Update data to reflect removed persona
|
||||
if (data) {
|
||||
data = {
|
||||
...data,
|
||||
personaIds: selectedPersonas.map((p) => p.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddGuests(userIds: number[]) {
|
||||
|
|
@ -281,18 +338,52 @@
|
|||
selectedPersonas.length === 0
|
||||
)
|
||||
return
|
||||
const chatData: any = { name: data.chat.name }
|
||||
if (data.chat.scenario.trim()) chatData.scenario = data.chat.scenario
|
||||
if (selectedCharacters.length > 1)
|
||||
chatData.group_reply_strategy = data.chat.groupReplyStrategy
|
||||
|
||||
// Ensure data is synced with current selections
|
||||
const characterIds = selectedCharacters.map((c) => c.id)
|
||||
const personaIds = selectedPersonas.map((p) => p.id)
|
||||
// characterPositions is now always up-to-date in data.characterPositions
|
||||
const characterPositions = Object.fromEntries(
|
||||
selectedCharacters.map((cc, i) => [cc.id, i])
|
||||
)
|
||||
|
||||
console.log("Creating chat with:", { characterIds, personaIds, characterPositions, selectedCharacters: selectedCharacters.length, selectedPersonas: selectedPersonas.length })
|
||||
|
||||
// Update data to ensure everything is in sync
|
||||
if (data) {
|
||||
data = {
|
||||
...data,
|
||||
characterIds,
|
||||
personaIds,
|
||||
characterPositions,
|
||||
chat: {
|
||||
...data.chat,
|
||||
name: name.trim(),
|
||||
scenario: scenario.trim(),
|
||||
groupReplyStrategy: groupReplyStrategy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chat && chat.id) {
|
||||
const updateChat: Sockets.UpdateChat.Call = data
|
||||
const updateChat: Sockets.UpdateChat.Call = data!
|
||||
socket.emit("chats:update", updateChat)
|
||||
} else {
|
||||
const createChat: Sockets.CreateChat.Call = data
|
||||
const createChat: Sockets.CreateChat.Call = {
|
||||
chat: {
|
||||
name: name.trim(),
|
||||
scenario: scenario.trim(),
|
||||
groupReplyStrategy: groupReplyStrategy,
|
||||
lorebookId: lorebookId,
|
||||
connectionId: chatConnectionId,
|
||||
samplingConfigId: chatSamplingConfigId,
|
||||
promptConfigId: chatPromptConfigId
|
||||
} as any,
|
||||
characterIds,
|
||||
personaIds,
|
||||
characterPositions,
|
||||
tags: selectedTags
|
||||
}
|
||||
console.log("Emitting chats:create with:", createChat)
|
||||
socket.emit("chats:create", createChat)
|
||||
}
|
||||
isCreating = false
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ export const chatsCreateHandler: Handler<
|
|||
const tags = params.tags || []
|
||||
const personaIds = params.personaIds || []
|
||||
const characterIds = params.characterIds || []
|
||||
const characterPositions = params.characterPositions || {}
|
||||
|
||||
// Remove tags from chat data as it will be handled separately
|
||||
const chatDataWithoutTags = { ...params.chat }
|
||||
|
|
@ -311,14 +312,16 @@ export const chatsCreateHandler: Handler<
|
|||
if (tags.length > 0) {
|
||||
await processChatTags(newChat.id, tags, userId)
|
||||
}
|
||||
for (const personaId of personaIds) {
|
||||
for (let i = 0; i < personaIds.length; i++) {
|
||||
const personaId = personaIds[i]
|
||||
await db.insert(schema.chatPersonas).values({
|
||||
chatId: newChat.id,
|
||||
personaId
|
||||
personaId,
|
||||
position: i
|
||||
})
|
||||
}
|
||||
for (const characterId of characterIds) {
|
||||
const position = params.characterPositions[characterId] || 0
|
||||
const position = characterPositions[characterId] || 0
|
||||
await db.insert(schema.chatCharacters).values({
|
||||
chatId: newChat.id,
|
||||
characterId,
|
||||
|
|
@ -808,6 +811,62 @@ export const chatsUpdateHandler: Handler<
|
|||
// Process tags after chat update
|
||||
await processChatTags(params.chat.id!, tags, userId)
|
||||
|
||||
// Sync chatCharacters if provided
|
||||
if (params.characterIds !== undefined) {
|
||||
const existingCCs = await db.query.chatCharacters.findMany({
|
||||
where: (cc, { eq }) => eq(cc.chatId, params.chat.id!)
|
||||
})
|
||||
const existingCharacterIds = new Set(existingCCs.map((cc) => cc.characterId).filter((id): id is number => id !== null))
|
||||
const newCharacterIds = new Set(params.characterIds)
|
||||
|
||||
for (const cc of existingCCs) {
|
||||
if (cc.characterId === null) continue
|
||||
if (!newCharacterIds.has(cc.characterId)) {
|
||||
await db.delete(schema.chatCharacters).where(
|
||||
and(eq(schema.chatCharacters.chatId, params.chat.id!), eq(schema.chatCharacters.characterId, cc.characterId))
|
||||
)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < params.characterIds.length; i++) {
|
||||
const characterId = params.characterIds[i]
|
||||
const position = (params.characterPositions ?? {})[characterId] ?? i
|
||||
if (existingCharacterIds.has(characterId)) {
|
||||
await db.update(schema.chatCharacters)
|
||||
.set({ position })
|
||||
.where(and(eq(schema.chatCharacters.chatId, params.chat.id!), eq(schema.chatCharacters.characterId, characterId)))
|
||||
} else {
|
||||
await db.insert(schema.chatCharacters).values({ chatId: params.chat.id!, characterId, position })
|
||||
}
|
||||
}
|
||||
await db.update(schema.chats)
|
||||
.set({ isGroup: params.characterIds.length > 1 })
|
||||
.where(eq(schema.chats.id, params.chat.id!))
|
||||
}
|
||||
|
||||
// Sync chatPersonas if provided
|
||||
if (params.personaIds !== undefined) {
|
||||
const existingCPs = await db.query.chatPersonas.findMany({
|
||||
where: (cp, { eq }) => eq(cp.chatId, params.chat.id!)
|
||||
})
|
||||
const existingPersonaIds = new Set(existingCPs.map((cp) => cp.personaId).filter((id): id is number => id !== null))
|
||||
const newPersonaIds = new Set(params.personaIds)
|
||||
|
||||
for (const cp of existingCPs) {
|
||||
if (cp.personaId === null) continue
|
||||
if (!newPersonaIds.has(cp.personaId)) {
|
||||
await db.delete(schema.chatPersonas).where(
|
||||
and(eq(schema.chatPersonas.chatId, params.chat.id!), eq(schema.chatPersonas.personaId, cp.personaId))
|
||||
)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < params.personaIds.length; i++) {
|
||||
const personaId = params.personaIds[i]
|
||||
if (!existingPersonaIds.has(personaId)) {
|
||||
await db.insert(schema.chatPersonas).values({ chatId: params.chat.id!, personaId, position: i })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch updated chat
|
||||
const updatedChat = await getChatFromDB(params.chat.id!, userId)
|
||||
if (!updatedChat) {
|
||||
|
|
|
|||
|
|
@ -438,6 +438,10 @@ declare global {
|
|||
namespace Update {
|
||||
interface Params {
|
||||
chat: UpdateChat
|
||||
characterIds?: number[]
|
||||
personaIds?: number[]
|
||||
characterPositions?: Record<number, number>
|
||||
tags?: string[]
|
||||
}
|
||||
interface Response {
|
||||
chat: SelectChat
|
||||
|
|
|
|||
|
|
@ -210,12 +210,15 @@
|
|||
|
||||
function startChatWithCharacter(character: Partial<SelectCharacter>) {
|
||||
if (!character.id) return
|
||||
const personaId = personas[0]?.id
|
||||
socket.emit("chats:create", {
|
||||
chat: {
|
||||
name: `Chat with ${character.nickname || character.name || "Character"}`,
|
||||
isGroup: false
|
||||
},
|
||||
characterIds: [character.id]
|
||||
characterIds: [character.id],
|
||||
personaIds: personaId ? [personaId] : [],
|
||||
characterPositions: { [character.id]: 0 }
|
||||
} as any)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue