diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1b1ae32 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..77948c3 --- /dev/null +++ b/.github/workflows/docker.yml @@ -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 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..a00f32f --- /dev/null +++ b/DOCKER.md @@ -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 \ + . +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5cc33ec --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 2dd5aa9..08bc754 100644 --- a/README.md +++ b/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. diff --git a/docker-compose.dist.yml b/docker-compose.dist.yml new file mode 100644 index 0000000..d3e505e --- /dev/null +++ b/docker-compose.dist.yml @@ -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: diff --git a/src/lib/client/components/chatForms/EditChatForm.svelte b/src/lib/client/components/chatForms/EditChatForm.svelte index 2a2eb50..9b0d810 100644 --- a/src/lib/client/components/chatForms/EditChatForm.svelte +++ b/src/lib/client/components/chatForms/EditChatForm.svelte @@ -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 diff --git a/src/lib/server/sockets/chats.ts b/src/lib/server/sockets/chats.ts index 7f39f1b..0ba93f6 100644 --- a/src/lib/server/sockets/chats.ts +++ b/src/lib/server/sockets/chats.ts @@ -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) { diff --git a/src/lib/shared/sockets/types.ts b/src/lib/shared/sockets/types.ts index 4dc1d08..882a785 100644 --- a/src/lib/shared/sockets/types.ts +++ b/src/lib/shared/sockets/types.ts @@ -438,6 +438,10 @@ declare global { namespace Update { interface Params { chat: UpdateChat + characterIds?: number[] + personaIds?: number[] + characterPositions?: Record + tags?: string[] } interface Response { chat: SelectChat diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4422977..d65c407 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -210,12 +210,15 @@ function startChatWithCharacter(character: Partial) { 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) }