Fix edit chat form, add dist docker build

This commit is contained in:
doolijb 2026-07-06 16:45:14 -07:00
parent df857c6eb8
commit 6e0ff59f3f
10 changed files with 587 additions and 29 deletions

27
.dockerignore Normal file
View 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
View 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
View 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
View 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"]

View file

@ -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
View 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:

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -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)
}