Merge pull request #65 from doolijb/develop

0.4 release
This commit is contained in:
Jody Doolittle 2025-08-17 18:22:57 -07:00 committed by GitHub
commit 308f134a72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
185 changed files with 44369 additions and 12355 deletions

View file

@ -1,10 +1,39 @@
# DEPRECATED - SQLITE will be removed in 0.4.0
DATABASE_URL=main.db # Optional, defaults to <OS Data Dir>/SerenePub/main.db
# Serene Pub 0.4.1 - Development Configuration
# Copy this file to .env to customize development settings
SOCKETS_USE_HTTPS=0 # Optional, defaults to 0 (HTTP), set to 1 for HTTPS
SOCKETS_PORT=3001 # Optional, defaults to 3001
# ===========================================
# APPLICATION DATA DIRECTORY
# ===========================================
# Override default data directory for development
# Default: Uses OS-appropriate directories
# Example for local development:
# SERENE_PUB_DATA_DIR=./dev-data
POSTGRES_BASE_URL=localhost
POSTGRES_PORT=3002
POSTGRES_USER=postgres
POSTGRES_PASSWORD=password
# ===========================================
# WEBSOCKET CONFIGURATION
# ===========================================
# WebSocket server HTTP mode
# Default: http
# SOCKETS_HTTP_MODE=https
# WebSocket server port
# Default: 3001
# SOCKETS_PORT=3001
# ===========================================
# DEVELOPMENT DATABASE (External PostgreSQL)
# ===========================================
# These are only used when connecting to external PostgreSQL for development
# Production uses embedded PGlite automatically
# PostgreSQL connection for development
# DATABASE_PORT=5432
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=password
# ===========================================
# DEVELOPMENT OPTIONS
# ===========================================
# Automatically open browser on startup (default: enabled)
# Set to 1 to disable automatic browser opening
# SERENE_AUTO_OPEN=1

View file

@ -1,99 +1,211 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
- 'v*.*.*-alpha'
push:
tags:
- "v*.*.*"
- "v*.*.*-alpha"
jobs:
build-and-release:
strategy:
matrix:
include:
- target: linux-x64
os: ubuntu-latest
- target: linux-arm64
os: ubuntu-latest
- target: linux-arm
os: ubuntu-latest
- target: linux-ppc64
os: ubuntu-latest
# - target: macos-x64
# os: macos-latest
- target: macos-arm64
os: macos-latest
- target: windows-x64
os: windows-latest
- target: windows-arm64
os: windows-latest
runs-on: ${{ matrix.os }}
env:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
node -e "const fs = require('fs'); try { fs.unlinkSync('package-lock.json'); } catch (e) { /* ignore */ }"
npm install
- name: Prepare SvelteKit
run: npx @sveltejs/kit sync
- name: Build app
run: npm run build
- name: Prepare production dependencies
shell: bash
run: |
# Install production dependencies
npm install --production
npm rebuild
# Clean node_modules with modclean, preserving license files
npx modclean --run --patterns="default:safe,default:caution,default:danger" --ignore="**/LICENSE,**/COPYING,**/NOTICE,**/README*,**/COPYRIGHT,**/AUTHORS,**/CONTRIBUTORS"
- name: Bundle for ${{ matrix.target }}
run: node scripts/bundle-dist.js ${{ matrix.target }}
- name: Zip dist directory (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
cd dist
$distDir = Get-ChildItem -Directory -Name "serene-pub-*-${{ matrix.target }}" | Select-Object -First 1
if (-not $distDir) {
Write-Error "No directory found matching serene-pub-*-${{ matrix.target }}"
Get-ChildItem
exit 1
}
Compress-Archive -Path $distDir -DestinationPath "../serene-pub-${{ github.ref_name }}-${{ matrix.target }}.zip"
- name: Zip dist directory (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
cd dist
DIST_DIR=$(ls -d serene-pub-*-${{ matrix.target }} 2>/dev/null | head -1)
if [ -z "$DIST_DIR" ]; then
echo "Error: No directory found matching serene-pub-*-${{ matrix.target }}"
ls -la
exit 1
fi
zip -r "../serene-pub-${{ github.ref_name }}-${{ matrix.target }}.zip" "$DIST_DIR"
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
files: serene-pub-*.zip
prerelease: ${{ contains(github.ref, 'alpha') }}
build-and-release:
strategy:
matrix:
include:
- target: linux-x64
os: ubuntu-latest
- target: linux-arm64
os: ubuntu-latest
- target: linux-arm
os: ubuntu-latest
- target: linux-ppc64
os: ubuntu-latest
# - target: macos-x64
# os: macos-latest
- target: macos-arm64
os: macos-latest
- target: windows-x64
os: windows-latest
- target: windows-arm64
os: windows-latest
runs-on: ${{ matrix.os }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: "--max-old-space-size=4096"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: |
node -e "const fs = require('fs'); try { fs.unlinkSync('package-lock.json'); } catch (e) { /* ignore */ }"
npm install
- name: Prepare SvelteKit
run: npx @sveltejs/kit sync
- name: Build app
run: npm run build
- name: Create platform-specific executables and icons
run: node scripts/create-executables.js
- name: Prepare production dependencies
shell: bash
run: |
# Install production dependencies
npm install --production
npm rebuild
# Clean node_modules with modclean, preserving license files
npx modclean --run --patterns="default:safe,default:caution,default:danger" --ignore="**/LICENSE,**/COPYING,**/NOTICE,**/README*,**/COPYRIGHT,**/AUTHORS,**/CONTRIBUTORS"
- name: Download Node.js runtime for target platform
shell: bash
run: |
echo "Downloading Node.js for ${{ matrix.target }}..."
mkdir -p temp-node
cd temp-node
case "${{ matrix.target }}" in
linux-x64)
NODE_URL="https://nodejs.org/dist/v20.13.1/node-v20.13.1-linux-x64.tar.xz"
curl -L -o node.tar.xz "$NODE_URL"
tar -xf node.tar.xz
cp node-v20.13.1-linux-x64/bin/node ../node
;;
linux-arm64)
NODE_URL="https://nodejs.org/dist/v20.13.1/node-v20.13.1-linux-arm64.tar.xz"
curl -L -o node.tar.xz "$NODE_URL"
tar -xf node.tar.xz
cp node-v20.13.1-linux-arm64/bin/node ../node
;;
linux-arm)
NODE_URL="https://nodejs.org/dist/v20.13.1/node-v20.13.1-linux-armv7l.tar.xz"
curl -L -o node.tar.xz "$NODE_URL"
tar -xf node.tar.xz
cp node-v20.13.1-linux-armv7l/bin/node ../node
;;
linux-ppc64)
NODE_URL="https://nodejs.org/dist/v20.13.1/node-v20.13.1-linux-ppc64le.tar.xz"
curl -L -o node.tar.xz "$NODE_URL"
tar -xf node.tar.xz
cp node-v20.13.1-linux-ppc64le/bin/node ../node
;;
macos-arm64)
NODE_URL="https://nodejs.org/dist/v20.13.1/node-v20.13.1-darwin-arm64.tar.gz"
curl -L -o node.tar.gz "$NODE_URL"
tar -xzf node.tar.gz
cp node-v20.13.1-darwin-arm64/bin/node ../node
;;
windows-x64)
NODE_URL="https://nodejs.org/dist/v20.13.1/node-v20.13.1-win-x64.zip"
curl -L -o node.zip "$NODE_URL"
unzip -q node.zip
cp node-v20.13.1-win-x64/node.exe ../node.exe
;;
windows-arm64)
NODE_URL="https://nodejs.org/dist/v20.13.1/node-v20.13.1-win-arm64.zip"
curl -L -o node.zip "$NODE_URL"
unzip -q node.zip
cp node-v20.13.1-win-arm64/node.exe ../node.exe
;;
esac
cd ..
rm -rf temp-node
# Verify the Node.js binary was downloaded
if [[ "${{ matrix.target }}" == windows-* ]]; then
if [ ! -f "node.exe" ]; then
echo "ERROR: Failed to download node.exe"
exit 1
fi
echo "Downloaded node.exe ($(du -h node.exe | cut -f1))"
else
if [ ! -f "node" ]; then
echo "ERROR: Failed to download node binary"
exit 1
fi
chmod +x node
echo "Downloaded node binary ($(du -h node | cut -f1))"
fi
- name: Bundle for ${{ matrix.target }}
run: node scripts/bundle-dist.js ${{ matrix.target }}
- name: Create production environment example
shell: bash
run: |
cd dist
DIST_DIR=$(ls -d serene-pub-*-${{ matrix.target }} 2>/dev/null | head -1)
if [ -z "$DIST_DIR" ]; then
echo "Error: No directory found matching serene-pub-*-${{ matrix.target }}"
ls -la
exit 1
fi
cat > "$DIST_DIR/.env.example" << 'EOF'
# Serene Pub 0.4.1 - Production Configuration
# Copy this file to .env in the same directory to customize settings
# ===========================================
# APPLICATION DATA DIRECTORY
# ===========================================
# Override default data directory location
# Default: OS-appropriate directory (~/.local/share/SerenePub on Linux, %APPDATA%/SerenePub on Windows, ~/Library/Application Support/SerenePub on macOS)
# Example for custom location:
# SERENE_PUB_DATA_DIR=/path/to/custom/data
# ===========================================
# SERVER CONFIGURATION
# ===========================================
# WebSocket server port
# Default: 3001
# SOCKETS_PORT=3001
# HTTP server port (main application)
# Default: 3000
# PORT=3000
# Disable automatic browser opening
# Default: Opens browser automatically on startup
# Set to 1 to disable auto-open
# SERENE_AUTO_OPEN=1
EOF
- name: Zip dist directory (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
cd dist
$distDir = Get-ChildItem -Directory -Name "serene-pub-*-${{ matrix.target }}" | Select-Object -First 1
if (-not $distDir) {
Write-Error "No directory found matching serene-pub-*-${{ matrix.target }}"
Get-ChildItem
exit 1
}
Compress-Archive -Path $distDir -DestinationPath "../serene-pub-${{ github.ref_name }}-${{ matrix.target }}.zip"
- name: Zip dist directory (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
cd dist
DIST_DIR=$(ls -d serene-pub-*-${{ matrix.target }} 2>/dev/null | head -1)
if [ -z "$DIST_DIR" ]; then
echo "Error: No directory found matching serene-pub-*-${{ matrix.target }}"
ls -la
exit 1
fi
zip -r "../serene-pub-${{ github.ref_name }}-${{ matrix.target }}.zip" "$DIST_DIR"
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
files: serene-pub-*.zip
prerelease: ${{ contains(github.ref, 'alpha') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

34
.vscode/launch.json vendored
View file

@ -1,18 +1,18 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Vite dev server",
"program": "${workspaceFolder}/node_modules/vite/bin/vite.js",
"args": ["--port", "5173", "--host"],
"autoAttachChildProcesses": true,
"env": {
"NODE_ENV": "development"
},
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Vite dev server",
"program": "${workspaceFolder}/node_modules/vite/bin/vite.js",
"args": ["--port", "5173", "--host"],
"autoAttachChildProcesses": true,
"env": {
"NODE_ENV": "development"
},
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}

56
.vscode/settings.json vendored
View file

@ -1,29 +1,29 @@
{
"cSpell.words": [
"Chara",
"chatml",
"cooldown",
"dndzone",
"dynatemp",
"GGUF",
"HAMLINDIGO",
"initialise",
"LLAMACPP",
"lmstudio",
"logit",
"lorebook",
"lorebooks",
"mirostat",
"Ngram",
"nsigma",
"Ollama",
"onconsider",
"onfinalize",
"pglite",
"Roleplay",
"skeletonlabs",
"skio",
"Tekken",
"uuidv"
]
}
"cSpell.words": [
"Chara",
"chatml",
"cooldown",
"dndzone",
"dynatemp",
"GGUF",
"HAMLINDIGO",
"initialise",
"LLAMACPP",
"lmstudio",
"logit",
"lorebook",
"lorebooks",
"mirostat",
"Ngram",
"nsigma",
"Ollama",
"onconsider",
"onfinalize",
"pglite",
"Roleplay",
"skeletonlabs",
"skio",
"Tekken",
"uuidv"
]
}

View file

@ -1,6 +1,6 @@
# Serene Pub - AI Assistant Reference
**Version**: 0.2.2
**Version**: 0.4.1
**License**: AGPL-3.0
**Author**: Jody Doolittle
**Repository**: https://github.com/doolijb/serene-pub
@ -18,6 +18,7 @@ Serene Pub is a modern, customizable chat application designed for immersive rol
## Software Stack
### Frontend
- **SvelteKit** - Full-stack Svelte framework (using Svelte 5)
- **Tailwind CSS** - Utility-first CSS framework
- **Skeleton UI** - Component library built on Tailwind for theming and components
@ -25,21 +26,23 @@ Serene Pub is a modern, customizable chat application designed for immersive rol
- **Socket.IO Client** - Real-time bidirectional communication via `sveltekit-io`
### Backend
- **Node.js** - JavaScript runtime
- **SvelteKit Server** - Server-side rendering and API routes
- **Socket.IO Server** - Real-time WebSocket communication via `sveltekit-io`
- **PostgreSQL** - Database via pglite
- **Drizzle ORM** - Type-safe SQL ORM with migrations
- **TypeScript** - Server-side type safety
- **Sqlite** - Deprecated database, removing in 0.4.0
### Development & Build Tools
- **Npm** - Package manager and runtime (preferred for development), bun may not be compatible with embedded postgres
- **Vite** - Build tool and dev server
- **Prettier** - Code formatting
- **Svelte Check** - TypeScript checking for Svelte files
### AI & Connection Adapters
- **BaseConnectionAdapter** - Base class for adding new LLM API's
- **OpenAI API** - ChatGPT and compatible APIs - chat completions only
- **Ollama** - Ollama's native API - chat and generation endpoints
@ -50,6 +53,7 @@ Serene Pub is a modern, customizable chat application designed for immersive rol
- **ConnectionTypes** - Class used as enum for connection adapter options, add new adapters here
### Key Dependencies
- `@sveltejs/adapter-node` - Node.js deployment adapter
- `drizzle-orm` & `drizzle-kit` - Database ORM and migration tools
- `pglite` - Embedded PostgreSQL for portable deployment
@ -63,6 +67,7 @@ Serene Pub is a modern, customizable chat application designed for immersive rol
## Application Architecture
### Project Structure
```
src/
├── app.html # Base HTML template
@ -95,6 +100,7 @@ src/
**Database**: PostgreSQL with Drizzle ORM
**Key Tables**:
- `users` - User accounts and preferences
- `connections` - AI service connection configurations
- `samplingConfigs` - AI generation parameters (temperature, top_k, etc.)
@ -114,11 +120,13 @@ src/
**WebSocket Architecture**: Built on Socket.IO via `sveltekit-io`
**Socket Namespace Structure**:
- All events are typed in `app.d.ts` under the `Sockets` namespace
- Each entity type has CRUD operations (Create, Read, Update, Delete, List)
- Real-time synchronization across multiple browser tabs/devices
**Key Socket Events**:
- User management: `user`, `updateUser`
- Chat operations: `chatsList`, `chat`, `createChat`, `sendPersonaMessage`, `triggerGenerateMessage`
- Character management: `characterList`, `character`, `createCharacter`, `updateCharacter`
@ -130,12 +138,14 @@ src/
**Base Architecture**: Abstract `BaseConnectionAdapter` class with implementations for different AI services
**Connection Adapters**:
- `OpenAIChatAdapter` - OpenAI API and compatible services
- `OllamaAdapter` - Local Ollama installations
- `LMStudioAdapter` - LM Studio local server
- `LlamaCppAdapter` - Direct llama.cpp integration
**Key Features**:
- Async generation with abort capability
- Token counting and context management
- Model listing and testing
@ -146,6 +156,7 @@ src/
**Core Component**: `PromptBuilder` class in `/src/lib/server/utils/promptBuilder.ts`
**Features**:
- Handlebars template engine for dynamic prompt construction
- Token counting and context window management
- Modular prompt sections (system, characters, personas, chat history)
@ -153,6 +164,7 @@ src/
- Support for both string prompts and message arrays
**Template Structure**:
- System instructions
- Character definitions (JSON format)
- User personas (JSON format)
@ -162,6 +174,7 @@ src/
### UI/UX Architecture
**Component Structure**:
- **Layouts**: Root layout with sidebar navigation
- **Pages**: Main chat interface, character/persona management
- **Sidebars**: Context-sensitive sidebars for configuration
@ -169,20 +182,24 @@ src/
- **Chat Components**: Message display, input handling, generation controls
**State Management**:
- Svelte 5 runes for reactive state
- Socket.IO for real-time data synchronization
- Context APIs for global state (user, active chat)
**Svelte 5 Syntax**
- Do not use svelte 4 `$:` or `on:` syntax
- Use svelte 5 `$effect(()=>{})`, `$state()`, `$derived()` || `$derived.by(()=>{})` syntax
**JS/TS Formatting**
- Do not use semi-colons unless required, i.e. inline
- Do not use single quotes unless required, i.e. compound/nested sentenced
- Use 4 space tabs
**Responsive Design**:
- Mobile-first responsive layout
- Skeleton UI components for consistent theming
- Dark/light theme support
@ -190,6 +207,7 @@ src/
## Key Configuration Files
### Development
- `package.json` - Dependencies and scripts
- `tsconfig.json` - TypeScript configuration
- `svelte.config.js` - Svelte/SvelteKit configuration
@ -197,11 +215,13 @@ src/
- `tailwind.config.js` - Tailwind CSS configuration
### Database
- `src/lib/server/db/drizzle.config.ts` - Database connection and migration config
- `src/lib/server/db/schema.ts` - Database schema definitions
- `drizzle/` - Migration files and metadata
### Deployment
- `dist-assets/` - Platform-specific deployment scripts
- `scripts/bundle-dist.js` - Build bundling script
- Platform launchers for Windows (`run.cmd`), Linux/macOS (`run.sh`)
@ -209,6 +229,7 @@ src/
## Deployment Architecture
### Portable Deployment
- Self-contained executable with embedded Node.js runtime
- Embedded PostgreSQL database for data persistence
- Platform-specific launchers handle privilege management
@ -217,11 +238,13 @@ src/
- Distributed modules must have compatible licenses that permit redistribution
### Windows Considerations
- PostgreSQL requires non-admin privileges for security
- Automatic privilege dropping using PowerShell `Start-Process -Verb runAsUser`
- Alternative deployment via Docker/WSL2 for sandboxing
### Development Environment
1. Install Node.js, Bun, and optionally Ollama
2. Clone repository and run `bun install`
3. Run `bun run db:migrate` for database setup
@ -239,17 +262,20 @@ src/
## Integration Points
### Character Card Compatibility
- Import/export compatibility with Silly Tavern character cards
- Support for PNG metadata and JSON formats
- Character image handling and avatar management
### AI Service Integration
- Modular adapter system for easy extension
- Support for multiple concurrent connections
- Model discovery and testing capabilities
- Token counting for various model types
### File Handling
- Character card imports
- Avatar image uploads
- Lorebook imports
@ -258,12 +284,14 @@ src/
## Development Patterns
### Socket Event Handling
1. Define types in `app.d.ts` under `Sockets` namespace
2. Implement handlers in `/src/lib/server/sockets/`
3. Register in `/src/lib/server/sockets/index.ts`
4. Client-side listeners in Svelte components
### Database Operations
1. Define schema in `db/schema.ts`
2. Add new tables Select/Insert inference to `app.d.ts`
3. Generate migrations with `bun run db:generate`
@ -271,6 +299,7 @@ src/
5. Use Drizzle ORM queries in socket handlers
### UI Component Development
1. Create Svelte components in `/src/lib/client/components/`
2. Use Skeleton UI primitives for consistency
3. Implement real-time updates via Socket.IO

135
CLAUDE.md Normal file
View file

@ -0,0 +1,135 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Core Development
- `npm run dev` - Start development server on localhost:5173
- `npm run dev:host` - Start development server with host access
- `npm run build` - Build for production (includes SSR build and customization)
- `npm start` - Start production server from build
- `npm run preview` - Preview production build
### Code Quality
- `npm run check` - Run Svelte type checking
- `npm run check:watch` - Run type checking in watch mode
- `npm run format` - Format code with Prettier
- `npm run lint` - Check code formatting (use this for validation)
### Database Management
- `npm run db:generate` - Generate Drizzle migrations
- `npm run db:migrate` - Run database migrations
- `npm run db:studio` - Open Drizzle Studio for database inspection
### Distribution
- `npm run bundle` - Create distribution bundle
- `npm run dist` - Full build and bundle process
## Architecture Overview
### Tech Stack
- **Frontend**: SvelteKit 2.x with Svelte 5, TypeScript
- **Styling**: TailwindCSS 4.x with Skeleton UI components
- **Database**: Drizzle ORM with PostgreSQL (PGLite for embedded use)
- **Real-time**: WebSockets via sveltekit-io for live updates
- **Build**: Vite with Node.js adapter
### Project Structure
**Key Directories:**
- `src/lib/client/` - Client-side components and utilities
- `src/lib/server/` - Server-side logic (database, sockets, AI adapters)
- `src/lib/shared/` - Shared constants and utilities
- `src/routes/` - SvelteKit routes and API endpoints
**Core Components:**
- `src/lib/client/components/` - Reusable Svelte components organized by feature
- `src/lib/client/connectionForms/` - AI connection configuration forms
- `src/lib/server/connectionAdapters/` - AI model integration adapters
- `src/lib/server/sockets/` - WebSocket event handlers
- `src/lib/server/utils/promptBuilder/` - Advanced prompt compilation system
### Database Schema
The application uses a comprehensive schema defined in `src/lib/server/db/schema.ts`:
**Core Entities:**
- `users` - User accounts with active configuration references
- `connections` - AI model connections (OpenAI, Ollama, LM Studio, etc.)
- `characters` - AI-controlled characters with rich metadata
- `personas` - User-controlled characters/identities
- `chats` - Conversations (1:1 or group) with message history
- `lorebooks` - Advanced worldbuilding and context management
**Configuration Tables:**
- `samplingConfigs` - AI generation parameters (temperature, top-p, etc.)
- `contextConfigs` - Handlebars templates for prompt formatting
- `promptConfigs` - System prompts and instructions
### Real-time Architecture
WebSocket-based real-time updates using sveltekit-io:
- All CRUD operations emit to user rooms for live UI updates
- Socket handlers in `src/lib/server/sockets/` manage events
- Client connects to single user room for security
### AI Integration System
Modular adapter pattern for AI model support:
- `BaseConnectionAdapter` - Abstract base for all AI connections
- Individual adapters for OpenAI, Ollama, LM Studio, Llama.cpp
- `PromptBuilder` - Advanced prompt compilation with Handlebars templating
- Token counting and context management per connection type
### Prompt Builder System
Sophisticated prompt compilation system with:
- Handlebars-based templating with custom helpers
- Modular content inclusion strategies
- Lorebook integration with keyword matching (vectorization planned)
- Token-aware context truncation
- Multiple output formats (chat completion vs. text completion)
## Important Development Notes
### Database Migrations
Always run `npm run db:generate` after schema changes, then `npm run db:migrate` to apply.
### WebSocket Events
When adding new socket events, register them in `src/lib/server/sockets/index.ts` following the existing pattern.
### AI Adapter Development
New AI connection types should extend `BaseConnectionAdapter` and implement required methods. Add connection defaults and sampling key mappings.
### Lorebook System
The advanced lorebook system supports:
- World lore (global context)
- Character lore (character-specific context)
- History entries (timeline-based context)
- Binding system for dynamic character references
### Component Architecture
Components are organized by feature area. Form components follow consistent patterns for WebSocket-based CRUD operations with optimistic updates.
## Testing
No specific test framework is currently configured. Manual testing through the dev server is the primary approach.

227
KEYBINDINGS.md Normal file
View file

@ -0,0 +1,227 @@
# Serene Pub - Keyboard Navigation & Accessibility
This document outlines all keyboard shortcuts and navigation patterns available in Serene Pub to ensure the application is accessible to all users, including those who rely on assistive technologies.
## Global Navigation
### Panel Focus
- **Alt + [** - Focus left sidebar panel (if open, otherwise announces no sidebar is open)
- **Alt + ]** - Focus right sidebar panel (if open, otherwise announces no sidebar is open)
- **Alt + /** - Focus main content area
- **Alt + ,** - Focus site navigation area
### General Navigation
- **Tab** - Navigate forward through interactive elements
- **Shift + Tab** - Navigate backward through interactive elements
- **Enter** - Activate buttons, links, and other interactive elements
- **Escape** - Close modals, popups, and cancel operations
## Chat Interface Navigation
### Message Navigation (In Chat Views)
- `Alt + J` - Navigate to next message
- `Alt + K` - Navigate to previous message
- `Alt + Home` - Navigate to first message
- `Alt + End` - Navigate to last message
- `Shift + G` - Navigate to latest (most recent) message
- `Arrow Up/Down` - Navigate between messages when focused
- `Enter` - Focus message action buttons when on a message
### Message Actions
- `Ctrl/Cmd + R` - Refresh (regenerate) last response
- `Ctrl/Cmd + Left Arrow` - Swipe current message left (previous variation)
- `Ctrl/Cmd + Right Arrow` - Swipe current message right (next variation or generate new)
### Message Actions (when message is focused)
- **E** - Edit message (if editable)
- **D** - Delete message (with confirmation)
- **H** - Hide/unhide message
- **R** - Reply to message
- **S** - Swipe message (if swipes available)
### Advanced Message Actions
- **Ctrl/Cmd + R** - Refresh (regenerate) last response
- **Ctrl/Cmd + Left** - Swipe current message left (previous variation)
- **Ctrl/Cmd + Right** - Swipe current message right (next variation or generate new)
- **Shift + G** - Go to latest (most recent) message with scroll
### Composer
- **Ctrl/Cmd + Enter** - Send message
- **Shift + Enter** - Add line break in message
- **Ctrl/Cmd + /** - Focus message composer
## Sidebar Navigation
### Character Management
- **Tab** - Navigate between character list items
- **Enter** - Select character
- **E** - Edit character (when character is focused)
- **D** - Delete character (when character is focused)
- **F** - Toggle favorite status
- **V** - Cycle visibility setting (visible → minimal → hidden)
### Chat Management
- **Tab** - Navigate between chat list items
- **Enter** - Open chat
- **E** - Edit chat settings
- **D** - Delete chat
### Persona Management
- **Tab** - Navigate between persona list items
- **Enter** - Select persona
- **E** - Edit persona
- **D** - Delete persona
## Form Navigation
### Character/Persona Forms
- **Ctrl/Cmd + S** - Save changes
- **Escape** - Cancel/close form
- **Tab** - Navigate form fields
- **Ctrl/Cmd + Z** - Undo changes (in rich text areas)
- **Ctrl/Cmd + Y** - Redo changes (in rich text areas)
### Modal Dialogs
- **Escape** - Close modal
- **Enter** - Confirm action (when confirm button is focused)
- **Tab** - Navigate between modal buttons
## Screen Reader Features
### Message Announcements
When navigating messages, screen readers will announce:
- Message number in conversation
- Speaker name (character or persona)
- Message content preview
- Available actions
Example: "Chat Message 5: Alice: Hello there! How are you doing today? Actions available: Edit, Delete, Hide"
### List Item Announcements
When navigating lists, screen readers will announce:
- Item type and name
- Position in list
- Additional context
Example: "Character List Item: Bob - A mysterious traveler with ancient knowledge"
### Panel Focus Announcements
When focusing panels, screen readers will announce:
- Panel name and purpose
- Number of items (if applicable)
- Current selection
- Status messages when panels are not open
Example: "Characters Sidebar - 12 characters available, Bob selected"
Example: "No left sidebar is currently open" when pressing Alt+[ with no open left panel
### Action Feedback Announcements
When using keyboard shortcuts, screen readers will announce:
- **Navigation**: "Navigated to first/last/latest message"
- **Regeneration**: "Regenerating last response" or "No response available to regenerate"
- **Swiping**: "Swiped message left/right" or "Cannot swipe - no variations available"
- **Focus changes**: When moving between messages or panels
- **Site navigation**: "Site navigation focused" when using Alt+,
- **Panel status**: "No left/right sidebar is currently open" when attempting to focus closed panels
Example: "Navigated to latest message" when pressing Shift + G
Example: "Site navigation focused" when pressing Alt + ,
## Form Field Guidance
### Required Fields
- Required fields are marked with asterisks (*)
- Screen readers announce "required" for mandatory fields
### Field Descriptions
- Important fields include helpful descriptions
- Validation errors are announced immediately
- Field format requirements are provided upfront
### Rich Text Editing
- Rich text editors announce formatting options
- Current formatting state is conveyed to screen readers
- Keyboard shortcuts for formatting are available
## Search and Filtering
### Search Fields
- **/** - Focus search field in most contexts
- **Escape** - Clear search field
- **Enter** - Execute search
- **Arrow Down/Up** - Navigate search suggestions
### Filter Controls
- Screen readers announce current filter state
- Number of filtered results is announced
- Clear filter options are keyboard accessible
## Connection and Model Management
### Connection Testing
- Connection status is announced to screen readers
- Success/failure feedback is provided audibly
- Model availability is clearly communicated
## Accessibility Features
### High Contrast Support
- Application respects system high contrast settings
- Custom theme options maintain sufficient contrast ratios
### Reduced Motion
- Animations respect `prefers-reduced-motion` setting
- Essential motion is maintained for functionality
### Focus Management
- Focus is clearly visible with custom focus rings
- Focus is trapped in modals and dialogs
- Focus returns to triggering element when closing dialogs
### Error Handling
- Errors are announced to screen readers
- Error messages are associated with relevant form fields
- Recovery suggestions are provided when possible
## Mobile Accessibility
### Touch Navigation
- All interactive elements meet minimum touch target size (44px)
- Swipe gestures have keyboard alternatives
- Mobile navigation is fully keyboard accessible
### Voice Control
- All interactive elements have appropriate labels for voice control
- Action names are clear and unambiguous
## Tips for Screen Reader Users
1. **Landmark Navigation**: Use landmark navigation (headings, regions) to quickly move between sections
2. **Table Navigation**: Use table navigation commands in data-heavy sections
3. **Search Functionality**: Use in-page search to quickly locate specific content
4. **Settings Exploration**: Visit the Settings panel to customize accessibility preferences
## Reporting Accessibility Issues
If you encounter accessibility barriers while using Serene Pub:
1. Open the Settings panel (Alt + ] then navigate to Settings)
2. Find the "Report Issues" link
3. Include details about:
- Your assistive technology (screen reader, voice control, etc.)
- The specific issue encountered
- Steps to reproduce the problem
- Your operating system and browser
## Future Accessibility Improvements
Planned accessibility enhancements include:
- Custom keyboard shortcut configuration
- Voice command integration
- Enhanced mobile accessibility features
- Improved rich text editing accessibility
- Better support for low vision users
---
*This document is continuously updated as accessibility features are added and improved. Last updated: August 10, 2025*

View file

@ -1,5 +1,4 @@
NOTICE
======
# NOTICE
Copyright (C) 2025 Serene Pub (Jody Doolittle)
Source: https://github.com/doolijb/serene-pub
@ -8,12 +7,11 @@ Source: https://github.com/doolijb/serene-pub
Serene Pub is an original project and is not a rewrite, port, clone, or fork of SillyTavern, TavernAI, or any other project. It is, however, inspired by these projects and others in the open-source AI chat ecosystem. This project may use or adapt features from other projects where attributed in the source code or documentation.
Contributors
------------
## Contributors
- Jody Doolittle (doolijb)
Third-Party Attributions
------------------------
## Third-Party Attributions
This project includes material derived from the SillyTavern project (https://github.com/SillyTavern/SillyTavern), licensed under the GNU Affero General Public License v3.0 (AGPLv3):
@ -23,10 +21,37 @@ This project includes material derived from the SillyTavern project (https://git
All such material is used in accordance with the AGPLv3. See the SillyTavern and TavernAI repositories for original sources and further details.
Trees used:
- [Context template & prompt instructions](https://github.com/SillyTavern/SillyTavern/tree/f12c523fcd66caa6f632c5e5a04b94a8a7f05407)
Third-Party Distribution
------------------------
## Third-Party Distribution
Serene Pub releases are distributed with third party packages included to provide a low-friction experience for users. This includes packages licensed under a variety of OSI-approved licenses, including MIT, BSD, Apache-2.0, and Python-2.0. License texts are included per package. We do not claim any additional rights over these third party works. Licenses can be found in `node_modules/<package>/LICENSE` of the respective release distributions.
A huge thank-you to all upstream work that has made this project possible.
### Node.js Runtime
Serene Pub distribution packages include the Node.js runtime (https://nodejs.org/) to provide a standalone experience without requiring users to install Node.js separately. Node.js is licensed under the MIT License:
```
Copyright Node.js contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
```
A huge thank-you to all upstream work that has made this project possible.

264
README.md
View file

@ -5,37 +5,37 @@
> **⚠️ Serene Pub is in alpha! Expect bugs and rapid changes. This project is under heavy development.**
<p align="center">
<b><a href="https://github.com/doolijb/serene-pub">Repo</a>
<a href="https://github.com/doolijb/serene-pub/wiki/Home">Wiki</a>
<a href="https://github.com/doolijb/serene-pub/releases">Downloads</a>
<a href="https://github.com/doolijb/serene-pub/issues">Issues</a>
<a href="https://discord.gg/3kUx3MDcSa">Discord</a>
<a href="https://buymeacoffee.com/serenepub">Buy Me a Coffee</a></b>
<b><a href="https://github.com/doolijb/serene-pub/wiki">📚 Documentation</a>
<a href="https://github.com/doolijb/serene-pub/releases">⬇️ Downloads</a>
<a href="https://github.com/doolijb/serene-pub/issues">🐛 Issues</a>
<a href="https://discord.gg/3kUx3MDcSa">💬 Discord</a>
<a href="https://buymeacoffee.com/serenepub">☕ Buy Me a Coffee</a></b>
</p>
---
## Table of Contents
- [Why Serene Pub?](#-why-serene-pub)
- [Screenshots](#-screenshots)
- [Features](#-features)
- [Quick Start / Download](#-quick-start)
- [Installation & Setup](#installation--setup)
- [Documentation & Help](#-documentation--help)
- [Planned Features](#-planned-features)
- [Considered Features](#-considered-features)
- [How to Update](#-how-to-update)
- [Contributing](#-contributing)
- [License](#-license)
- [Special Thanks](#-special-thanks)
# 🦊 Serene Pub
**Modern, Open Source AI Roleplay Chat**
Serene Pub is a brand new, open source chat application for immersive AI roleplay and creative conversations. Designed for simplicity, speed, and beautiful usability, Serene Pub brings your characters and worlds to life—on your terms, with your data, and your favorite AI models.
## 📚 **[Full Documentation & Setup Guide](https://github.com/doolijb/serene-pub/wiki)**
**For detailed installation instructions, configuration guides, and tutorials, visit our [Wiki](https://github.com/doolijb/serene-pub/wiki).**
---
## Table of Contents
- [Why Serene Pub?](#-why-serene-pub)
- [Screenshots](#-screenshots)
- [Features](#-features)
- [Quick Start](#-quick-start)
- [Documentation](#-documentation)
- [Contributing](#-contributing)
- [License](#-license)
---
## ✨ Why Serene Pub?
@ -44,7 +44,10 @@ Serene Pub is a brand new, open source chat application for immersive AI rolepla
- **Real-Time Sync:** All chats, settings, and characters update live across devices via WebSockets.
- **Portable & Private:** Runs locally, no accounts, no cloud lock-in. Your data stays with you.
- **AI Freedom:** Connect to OpenAI, Ollama, LM Studio, Llama.cpp, and more. Mix and match models, run local or cloud.
- **Low fuss local AI**: Use Ollama manager to search, download and activate models all within the comfort of Serene Pub.
- **Roleplay-First:** Built for character-driven, story-rich experiences. Import Silly Tavern cards, manage personas, and more.
- **Coherence:** Some user's report characters adhere better to their profiles than other apps.
- **Group Chats:** Chat with as many characters at once as you wish.
- **Mobile Ready:** Responsive design for desktop and mobile. Pick up your story anywhere.
- **Open Source:** AGPL-3.0. Hack it, extend it, make it yours!
@ -54,36 +57,42 @@ Serene Pub is a brand new, open source chat application for immersive AI rolepla
### Desktop Experience
| Chat & Editing | Connections & Characters | Contexts & Lorebooks |
| -------------- | ------------------------ | -------------------- |
| Chat & Editing | Connections & Characters | Contexts & Lorebooks |
| --------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |
| ![](static/screenshots/desktop-chat-edit.png) | ![](static/screenshots/desktop-connections-characters.png) | ![](static/screenshots/desktop-contexts-lorebooks.png) |
| Prompt Details | Prompts & Chats | Sampling & Personas |
| -------------- | --------------- | ------------------- |
| Prompt Details | Prompts & Chats | Sampling & Personas |
| -------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------- |
| ![](static/screenshots/desktop-prompt-details.png) | ![](static/screenshots/desktop-prompts-chats.png) | ![](static/screenshots/desktop-sampling-personas.png) |
| Theme Example 1 | Theme Example 2 | Theme Example 3 |
| --------------- | --------------- | --------------- |
| Theme Example 1 | Theme Example 2 | Theme Example 3 |
| --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- |
| ![](static/screenshots/desktop-theme-example-1.png) | ![](static/screenshots/desktop-theme-example-2.png) | ![](static/screenshots/desktop-theme-example-3.png) |
| Theme Example 4 | Theme Example 5 |
| --------------- | --------------- |
| Theme Example 4 | Theme Example 5 |
| --------------------------------------------------- | --------------------------------------------------- |
| ![](static/screenshots/desktop-theme-example-4.png) | ![](static/screenshots/desktop-theme-example-5.png) |
### Lorebooks+ & Worldbuilding
| Character Bindings | Character Lore | Lorebook History | World Lore |
| ------------------ | -------------- | ---------------- | ---------- |
| Character Bindings | Character Lore | Lorebook History | World Lore |
| -------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
| ![](static/screenshots/lorebooks-character-bindings.png) | ![](static/screenshots/lorebooks-character-lore.png) | ![](static/screenshots/lorebooks-history.png) | ![](static/screenshots/lorebooks-world-lore.png) |
### Ollama Manager
| Available Models | Downloads | Installed Models | Settings |
| -------------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------ |
| ![](static/screenshots/sidebar-ollama-manager-available.png) | ![](static/screenshots/sidebar-ollama-manager-downloads.png) | ![](static/screenshots/sidebar-ollama-manager-installed.png) | ![](static/screenshots/sidebar-ollama-manager-settings.png) |
### Mobile Experience
| Chat | Connections | Edit Character |
| ---- | ----------- | -------------- |
| Chat | Connections | Edit Character |
| --------------------------------------- | ---------------------------------------------- | ------------------------------------------------- |
| ![](static/screenshots/mobile-chat.png) | ![](static/screenshots/mobile-connections.png) | ![](static/screenshots/mobile-edit-character.png) |
| Home | Navigation |
| ---- | ---------- |
| Home | Navigation |
| --------------------------------------- | --------------------------------------------- |
| ![](static/screenshots/mobile-home.png) | ![](static/screenshots/mobile-navigation.png) |
---
@ -91,20 +100,23 @@ Serene Pub is a brand new, open source chat application for immersive AI rolepla
## 🚀 Features
- **AI Model Agnostic:** Connect to OpenAI, Ollama, Llama.cpp, and more
- **Ollama Manager:** Built-in UI to easily manage, download, and activate Ollama models
- **Character & Persona Management:** Import, create, and edit with rich metadata and avatars
- **Lorebooks+:** Organize world lore, character lore, and history for deep roleplay
- **Group Chats:** Multi-character chats for immersive group roleplay and dynamic storylines
- **Tags:** Easily organize and filter chats, characters, personas, and lorebooks with customizable tags
- **Chat & Context Tools:**
- Auto character response
- Edit/delete messages
- Streaming & regenerate
- Manual & hidden responses
- Swipe left/right on messages
- Live token and history stats
- Auto character response
- Edit/delete messages
- Streaming & regenerate
- Manual & hidden responses
- Swipe left/right on messages
- Live token and history stats
- **Prompt Statistics:** View compiled prompts before sending
- **Context Templates:** Handlebar-based, customizable prompt formats
- **Mobile-First Design:** Fully responsive, works great on phones and tablets
- **Themes & Dark Mode:** 20+ themes, instant switching, and accessibility options
- **Accessibility & Screen Reader Support:** Experimental support for screen readers and assistive technologies (in progress)
- **Portable & Secure:** Embedded database, no cloud required, runs anywhere
- **Silly Tavern Compatibility:** Import/export character cards and avatars
- **Open Source & Extensible:** AGPL-3.0, modular adapters, easy to hack
@ -117,14 +129,14 @@ Serene Pub is a brand new, open source chat application for immersive AI rolepla
Linux, MacOS and Windows are supported!
1. [Download the latest release](https://github.com/doolijb/serene-pub/releases) for your OS
1. **[Download the latest release](https://github.com/doolijb/serene-pub/releases)** for your OS
2. Extract the archive anywhere
3. Read the included `INSTRUCTIONS.txt` for your platform
4. Run the launcher script (`run.sh`/`run.cmd`)
5. Open [http://localhost:3000](http://localhost:3000) in your browser
6. Add your first AI connection and start chatting!
### Source Code
### From Source
#### Requirements
@ -138,157 +150,38 @@ Linux, MacOS and Windows are supported!
3. `npm run dev` to start the dev server, or `npm run dev:host`
4. Visit [http://localhost:5173](http://localhost:5173)
**Need help?** Check out our **[Setup Guide](https://github.com/doolijb/serene-pub/wiki/Installation-&-Setup)** in the wiki.
---
## 📚 Documentation & Help
## <EFBFBD> Documentation
### 🧩 Context Configuration
### **[Complete Documentation Available in our Wiki](https://github.com/doolijb/serene-pub/wiki)**
Serene Pub uses Handlebars-style templates to build highly customizable prompts. Templates can include dynamic information like date, world lore, and structured history. Example:
**Popular Wiki Pages:**
- **[Installation & Setup](https://github.com/doolijb/serene-pub/wiki/Installation-&-Setup)** - Detailed setup instructions for all platforms
- **[Connections](https://github.com/doolijb/serene-pub/wiki/Connections)** - How to connect to AI models (OpenAI, Ollama, LM Studio, etc.)
- **[Characters & Personas](https://github.com/doolijb/serene-pub/wiki/Characters-&-Personas)** - Creating and managing your roleplay characters
- **[Lorebooks](https://github.com/doolijb/serene-pub/wiki/Lorebooks)** - Advanced world-building and context management
- **[Context Templates](https://github.com/doolijb/serene-pub/wiki/Context-Templates)** - Customizing AI prompts with Handlebars
- **[Troubleshooting](https://github.com/doolijb/serene-pub/wiki/Troubleshooting)** - Common issues and solutions
````hbs
{{#systemBlock}}
Instructions:
"""
{{#if currentDate}}
The current date in the story is {{{currentDate}}}.
{{/if}}
### 🗺️ Planned Features
{{{instructions}}}
"""
Assistant Characters (AI-controlled):
```json
{{{characters}}}
```
User Characters (player-controlled):
```json
{{{personas}}}
```
Scenario: """ {{{scenario}}} """
{{#if worldLore}} World lore:
```json
{{{worldLore}}}
```
{{/if}}
{{#if history}} Story history:
```json
{{{history}}}
```
{{/if}}
{{#if wiBefore}} {{{wiBefore}}} {{/if}} {{/systemBlock}}
{{#each chatMessages}} {{#if (eq role "assistant")}} {{#assistantBlock}} {{{name}}}: {{{message}}} {{/assistantBlock}} {{/if}} {{#if (eq role "user")}} {{#userBlock}} {{{name}}}: {{{message}}} {{/userBlock}} {{/if}} {{/each}}
{{#if wiAfter}} {{#systemBlock}} {{{wiAfter}}} {{/systemBlock}} {{/if}}
````
### 📝 Prompt Configuration
Prompt Configurations define the tone and behavior of the AI's responses. These instructions are sent alongside your message history and context blocks.
Example:
````text
Develop the plot slowly, always stay in character. Describe all actions in full, elaborate, explicit, graphic, and vivid detail. Mention all relevant sensory perceptions. Keep the story immersive and engaging.
````
### 👤 Personas
Personas are user-controlled characters. Give your persona a name, description, and avatar to represent yourself in chats. Personas let you roleplay as different characters, each with their own style and background.
### 🤖 Characters
Characters are AI-controlled participants in your chats. Each character can have a name, description, avatar, personality, and greeting.
- **Greeting:** The greeting is the first message a character inserts into the chat. It helps ground the AI and provides an example of how the character should behave. You can add additional greetings or group-only greetings for more variety.
- **Importing:** Character cards can be imported from Silly Tavern or your favorite character card website, making it easy to bring your favorite personalities into Serene Pub.
### 📖 Lorebooks+
Lorebooks+ are advanced worldbuilding and context management tools that let you deeply enrich your roleplay experience. With Lorebooks+, you can:
- **Character Bindings:** Hotswappable characters linked in the lorebook. Dynamically update character and persona names in lorebook entries, so the right information is injected into the prompt for the current cast of your chat.
- **World Lore:** Store and organize facts, rules, and background information about your world, setting, or universe. World lore can be automatically included in relevant chats.
- *Imported lorebooks/world books are inserted into "world lore" for easy access and editing.*
- **Character Lore:** Linked to character bindings and extends the character profiles. Maintain detailed backstories, traits, and secrets for each character, ensuring the AI stays consistent and in-character.
- **History:** Track and inject important story events, chat history, or evolving facts as the narrative progresses. *History entries are experimental and may change in future releases.* This helps maintain continuity and depth in long-running stories.
To use a lorebook, select it when creating or editing a chat. Lorebook entries are triggered by keywords, but may also be pinned to ensure they are always present in the prompt. Vectorization will be added in the future as a replacement for keyword engineering, making context injection smarter and more flexible.
Lorebooks+ make it easy to manage complex worlds, keep characters consistent, and ensure the AI always has the right context for immersive storytelling.
### 💬 Chats
Create a chat by adding one or more characters and at least one persona. You can:
- Optionally add a scenario to tell the AI what the current objective is. In non-group chats, this overrides character scenarios.
- Enable or disable characters from responding automatically in group chats for more control over the conversation flow.
- Optionally select a lorebook to inject world, character, or historical lore into your chat for richer context and storytelling.
### 🏷️ Tags (Coming in 0.4.0)
Tags are a planned feature for the 0.4.0 release. Tags will help you organize and filter your content, making it easier to manage complex stories and worlds.
### 📂 Data Location
Your data is saved locally in your OS-specific app directory:
- **macOS:** `~/Library/Application Support/SerenePub`
- **Windows:** `%LOCALAPPDATA%\SerenePub`
- **Linux:** `~/.local/share/SerenePub`
### ⚡ Troubleshooting
- Adjust "Context Tokens" in the Sampling tab.
- Configure a more accurate tokenizer in the Connections sidebar.
- Use the "View Prompt Statistics" modal to preview your final prompt.
* Save your configuration to apply changes.
* Default templates marked with `*` cannot be edited directly; clone to customize.
- Ensure you are editing a cloned configuration, not the defaults.
### 🔌 Connections
Serene Pub supports a variety of AI model connections, both local and cloud-based. For a full list of currently supported (or in development) connection options, see the [Supported Connections Issue](https://github.com/doolijb/serene-pub/issues/10).
- **Connection Types:** Some adapters support chat APIs, completion APIs, or both. You can mix and match connections to suit your needs. When using completion APIs (Serene Pub precompiles the entire prompt), you can select from available prompt formats to best match your model's requirements.
- **Token Counter:** Each connection uses a token counter to estimate the number of tokens in your chat history, character definitions, and prompt. This helps manage context size and avoid exceeding model limits.
- **Setup:** Add and configure connections in the app sidebar. Test connections and refresh available models directly from the UI.
---
## 🗺️ Planned Features
- 🏷️ Tags (coming in 0.4.0)
- 🧠 Vectorization
- 🔌 More API connection types
- 🧠 Vectorization / embeddings
- 🤖 Assistant Chat: Ask AI questions about Serene Pub and get suggestions to improve your characters, personas, and lore
- 🦙 Ollama Manager UI: Manage, download, and update Ollama models directly from the app
## 💡 Considered Features
- 👥 Multi-user logins & multi-user group chats
- 🤖 Assistant Chat: In-chat OOC discussions
- 🖼️ Image generation
- 📝 Chat summarizing
- 👥 Multi-user logins & multi-user group chats
- 👥 Admin user account management
### 💡 Considered Features
- 🖼️ User/chat backgrounds
- 📖 Story narration/system instructions
- 🦯 Screen reader support
- 📅 Lorebooks+ features: custom calendars, "eras" historical categories, and more
- 🕹️ Text adventure & narrator modes
- 🖼️ Image generation
---
## 🔄 How to Update
**Updating from 0.2.x, 0.3.x:**
- Download the latest version and extract to your desired location and run. Your data will automatically be copied from the old database to the new, more powerful database.
**Updating to future versions:**
- Download the latest version and extract to your desired location and run (it doesn't matter where). Run the application. Any database migrations will be performed automatically.
---
@ -296,6 +189,8 @@ Serene Pub supports a variety of AI model connections, both local and cloud-base
Serene Pub is community-driven! Bug fixes, features, and feedback are welcome. Please [open an issue](https://github.com/doolijb/serene-pub/issues) or [start a discussion](https://github.com/doolijb/serene-pub/discussions) before submitting large changes.
**For development setup and contribution guidelines, see our [Contributing Guide](https://github.com/doolijb/serene-pub/wiki/Contributing).**
---
## 🛡️ License
@ -306,8 +201,11 @@ AGPL-3.0. See [LICENSE](LICENSE) and [NOTICE.md](NOTICE.md) for details.
## 🙏 Special Thanks
Special thanks to **crazyaphro** for Q/A, **M3d4r** for editing the Wiki, and **Nivelle** for early feedback.
Special thanks to **crazyaphro** and **Nivelle** for Q/A, **M3d4r** for editing the Wiki, and .
---
<p align="center"><b>Serene Pub — Play more, tweak less. 100% open source.</b></p>
<p align="center">
<b>Serene Pub — Play more, tweak less. 100% open source.</b><br>
<b>📚 <a href="https://github.com/doolijb/serene-pub/wiki">Read the full documentation</a></b>
</p>

View file

@ -25,9 +25,17 @@ To run the app:
- If you get a permission error, run: chmod +x run.sh
3. On first launch, the app will automatically download and set up a local Node.js runtime if needed. No manual installation is required.
3. Node.js runtime is included - no installation or download required! The application will start immediately.
4. On your host machine, access Serene Pub from http://0.0.0.0:3000 or http://localhost:3000.
4. To stop the application, press Ctrl+C in the terminal window.
5. Optional Configuration:
- Copy ".env.example" to ".env" to customize settings
- Edit ".env" to set custom data directory: SERENE_PUB_DATA_DIR=/path/to/data
- Change server ports: SOCKETS_PORT=3001, PORT=3000
- Disable auto-browser opening: SERENE_AUTO_OPEN=1
5. On your host machine, access Serene Pub from http://0.0.0.0:3000 or http://localhost:3000.
Notes:
- Serene Pub will attempt to automatically identify your local network IP address, allowing access across other devices (e.g. mobile phones.)

6
dist-assets/linux/Serene Pub Executable file
View file

@ -0,0 +1,6 @@
#!/bin/bash
# Serene Pub Application Launcher
# This launches the main run.sh script
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd "$DIR"
exec ./run.sh

View file

@ -0,0 +1,11 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Serene Pub
Comment=AI Chat Application
Exec=/home/jody/github/serene-pub/dist-assets/linux/run.sh
Icon=/home/jody/github/serene-pub/dist-assets/linux/favicon.png
Terminal=false
Categories=Network;Chat;
StartupNotify=true
Path=/home/jody/github/serene-pub/dist-assets/linux

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,24 +1,86 @@
#!/bin/sh
# Serene Pub Application Launcher
# Licensed under AGPL-3.0 - See LICENSE file
# Source: https://github.com/doolijb/serene-pub
DIR=$(dirname "$0")
export NODE_ENV=production
NODE_BIN="$DIR/node"
NODE_URL="https://nodejs.org/dist/v20.13.1/node-v20.13.1-linux-x64.tar.xz"
NODE_ARCHIVE="node-archive.linux.tar.xz"
NODE_DIR="node-v20.13.1-linux-x64"
NODE_BIN_PATH="$NODE_DIR/bin/node"
APP_MAIN="$DIR/build/index.js"
if [ ! -f "$NODE_BIN" ]; then
echo "Downloading Node.js..."
curl -L -o "$NODE_ARCHIVE" "$NODE_URL"
tar -xf "$NODE_ARCHIVE"
cp "$NODE_BIN_PATH" "$NODE_BIN"
rm -rf "$NODE_DIR"
rm -f "$NODE_ARCHIVE"
# Load environment variables from .env file if present
ENV_FILE="$DIR/.env"
if [ -f "$ENV_FILE" ]; then
echo "Loading environment variables from .env file..."
# Use a more portable way to load environment variables
while IFS='=' read -r key value; do
# Skip comments and empty lines
case "$key" in
'#'*|'') continue ;;
esac
# Export the variable, removing any surrounding quotes
export "$key"="$(echo "$value" | sed 's/^["'\'']\|["'\'']$//g')"
done < "$ENV_FILE"
fi
echo "========================================"
echo "Serene Pub - AI Chat Application"
echo "https://github.com/doolijb/serene-pub"
echo "========================================"
echo
# Verify Node.js runtime exists
if [ ! -f "$NODE_BIN" ]; then
echo "ERROR: Node.js runtime not found at $NODE_BIN"
echo "Please ensure all application files are present in this directory."
echo "Press Enter to exit..."
read
exit 1
fi
# Verify application files exist
if [ ! -f "$APP_MAIN" ]; then
echo "ERROR: Application file not found at $APP_MAIN"
echo "Please ensure all application files are present in this directory."
echo "Press Enter to exit..."
read
exit 1
fi
chmod +x "$NODE_BIN"
echo "Starting Serene Pub..."
"$NODE_BIN" "$DIR/build/index.js" "$@"
echo
echo "The application will be available at:"
echo " - http://localhost:3000"
echo " - http://127.0.0.1:3000"
echo
echo "Press Ctrl+C to stop the application."
echo "========================================"
echo
# Set up signal handling for graceful shutdown
trap 'echo; echo "Shutting down Serene Pub..."; kill $NODE_PID 2>/dev/null; wait $NODE_PID 2>/dev/null; echo "Serene Pub stopped."; exit 0' INT TERM
# Start the application in background to handle signals
"$NODE_BIN" "$APP_MAIN" "$@" &
NODE_PID=$!
# Wait for the Node.js process
wait $NODE_PID
EXIT_CODE=$?
echo
echo "========================================"
if [ $EXIT_CODE -eq 0 ]; then
echo "Serene Pub stopped normally."
else
echo "Serene Pub exited with code: $EXIT_CODE"
echo "Check the output above for any error messages."
fi
echo
echo "Press Enter to exit..."
read
exit $EXIT_CODE
read

View file

@ -0,0 +1,10 @@
To set up the application icon:
1. Convert favicon.png to favicon.icns using:
- sips command: sips -s format icns favicon.png --out Resources/favicon.icns
- Or use an online converter
- Or use Image2icon app
2. Place the favicon.icns file in: Serene Pub.app/Contents/Resources/
The .app bundle is ready to use after adding the icon.

View file

@ -11,12 +11,20 @@ To run the app:
3. Navigate to the extracted folder (e.g., serene-pub-<version>-macos):
cd /path/to/serene-pub-<version>-macos
3. Run the app with:
4. Run the app with:
bash run.sh
4. On first launch, the app will automatically download and set up a local Node.js runtime if needed. No manual installation is required.
5. Node.js runtime is included - no installation or download required! The application will start immediately.
5. On your Mac, access Serene Pub from http://0.0.0.0:3000 or http://localhost:3000.
6. To stop the application, press Ctrl+C in the terminal window.
7. Optional Configuration:
- Copy ".env.example" to ".env" to customize settings
- Edit ".env" to set custom data directory: SERENE_PUB_DATA_DIR=/path/to/data
- Change server ports: SOCKETS_PORT=3001, PORT=3000
- Disable auto-browser opening: SERENE_AUTO_OPEN=1
8. On your Mac, access Serene Pub from http://localhost:3000.
Notes:
- Serene Pub will attempt to automatically identify your local network IP address, allowing access across other devices (e.g. mobile phones.)

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>serene-pub</string>
<key>CFBundleIdentifier</key>
<string>com.doolijb.serene-pub</string>
<key>CFBundleName</key>
<string>Serene Pub</string>
<key>CFBundleVersion</key>
<string>0.4.1</string>
<key>CFBundleShortVersionString</key>
<string>0.4.1</string>
<key>CFBundleIconFile</key>
<string>favicon.icns</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
</dict>
</plist>

View file

@ -0,0 +1,7 @@
#!/bin/bash
# Serene Pub Application Launcher for macOS
# This launches the main run.sh script
APP_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
BUNDLE_DIR="$( cd "$APP_DIR/../.." &> /dev/null && pwd )"
cd "$BUNDLE_DIR"
exec ./run.sh

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,24 +1,86 @@
#!/bin/sh
# Serene Pub Application Launcher
# Licensed under AGPL-3.0 - See LICENSE file
# Source: https://github.com/doolijb/serene-pub
DIR=$(dirname "$0")
export NODE_ENV=production
NODE_BIN="$DIR/node"
NODE_URL="https://nodejs.org/dist/v20.13.1/node-v20.13.1-darwin-x64.tar.gz"
NODE_ARCHIVE="node-archive.macos.tar.gz"
NODE_DIR="node-v20.13.1-darwin-x64"
NODE_BIN_PATH="$NODE_DIR/bin/node"
APP_MAIN="$DIR/build/index.js"
if [ ! -f "$NODE_BIN" ]; then
echo "Downloading Node.js..."
curl -L -o "$NODE_ARCHIVE" "$NODE_URL"
tar -xzf "$NODE_ARCHIVE"
cp "$NODE_BIN_PATH" "$NODE_BIN"
rm -rf "$NODE_DIR"
rm -f "$NODE_ARCHIVE"
# Load environment variables from .env file if present
ENV_FILE="$DIR/.env"
if [ -f "$ENV_FILE" ]; then
echo "Loading environment variables from .env file..."
# Use a more portable way to load environment variables
while IFS='=' read -r key value; do
# Skip comments and empty lines
case "$key" in
'#'*|'') continue ;;
esac
# Export the variable, removing any surrounding quotes
export "$key"="$(echo "$value" | sed 's/^["'\'']\|["'\'']$//g')"
done < "$ENV_FILE"
fi
echo "========================================"
echo "Serene Pub - AI Chat Application"
echo "https://github.com/doolijb/serene-pub"
echo "========================================"
echo
# Verify Node.js runtime exists
if [ ! -f "$NODE_BIN" ]; then
echo "ERROR: Node.js runtime not found at $NODE_BIN"
echo "Please ensure all application files are present in this directory."
echo "Press Enter to exit..."
read
exit 1
fi
# Verify application files exist
if [ ! -f "$APP_MAIN" ]; then
echo "ERROR: Application file not found at $APP_MAIN"
echo "Please ensure all application files are present in this directory."
echo "Press Enter to exit..."
read
exit 1
fi
chmod +x "$NODE_BIN"
echo "Starting Serene Pub..."
"$NODE_BIN" "$DIR/build/index.js" "$@"
echo
echo "The application will be available at:"
echo " - http://localhost:3000"
echo " - http://127.0.0.1:3000"
echo
echo "Press Ctrl+C to stop the application."
echo "========================================"
echo
# Set up signal handling for graceful shutdown
trap 'echo; echo "Shutting down Serene Pub..."; kill $NODE_PID 2>/dev/null; wait $NODE_PID 2>/dev/null; echo "Serene Pub stopped."; exit 0' INT TERM
# Start the application in background to handle signals
"$NODE_BIN" "$APP_MAIN" "$@" &
NODE_PID=$!
# Wait for the Node.js process
wait $NODE_PID
EXIT_CODE=$?
echo
echo "========================================"
if [ $EXIT_CODE -eq 0 ]; then
echo "Serene Pub stopped normally."
else
echo "Serene Pub exited with code: $EXIT_CODE"
echo "Check the output above for any error messages."
fi
echo
echo "Press Enter to exit..."
read
exit $EXIT_CODE
read

View file

@ -0,0 +1,9 @@
To set up the application icon:
1. Convert favicon.png to favicon.ico using an online converter
2. Use Resource Hacker (http://www.angusj.com/resourcehacker/) to:
- Open "Serene Pub Launcher.bat"
- Add the favicon.ico as the application icon
- Save as "Serene Pub.exe"
Or use a batch-to-exe converter that supports custom icons.

View file

@ -5,12 +5,20 @@ Thank you for downloading Serene Pub.
To run the app:
1. Extract the archive to your preferred location.
2. Open the extracted folder (e.g., serene-pub-<version>-win).
3. Double-click the "run.cmd" file to start Serene Pub.
2. Double-click the "run.cmd" file to start Serene Pub.
- The first launch will automatically download and set up Node.js if needed. No manual installation is required.
- Node.js runtime is included - no installation or download required!
- The application will start immediately.
4. On your Windows PC, access Serene Pub from http://0.0.0.0:3000 or http://localhost:3000.
3. Optional Configuration:
- Copy ".env.example" to ".env" to customize settings
- Edit ".env" to set custom data directory: SERENE_PUB_DATA_DIR=C:\path\to\data
- Change server ports: SOCKETS_PORT=3001, PORT=3000
- Disable auto-browser opening: SERENE_AUTO_OPEN=1
4. On your Windows PC, access Serene Pub from http://localhost:3000.
5. To stop the application, press Ctrl+C in the command window.
Notes:
- Serene Pub will attempt to automatically identify your local network IP address, allowing access across other devices (e.g. mobile phones.)
@ -18,9 +26,9 @@ Notes:
- Look for the "IPv4 Address" under your active network adapter (e.g., 192.168.1.42). You can access Serene Pub from your phone at http://<your-ip>:3000
Troubleshooting:
- If double-clicking "run.cmd" does not start the app, try right-clicking and selecting "Run as administrator."
- If you see a security warning, click "More info" and then "Run anyway."
- If Node.js fails to download, check your internet connection or firewall settings.
- If double-clicking "run.cmd" does not start the app, you may need to run it as administrator
- Ensure all files were extracted properly from the ZIP archive
- This is open source software - you can review all code at https://github.com/doolijb/serene-pub
Uninstall:
- To remove Serene Pub, simply delete the extracted folder. No files are installed outside this directory.

View file

@ -0,0 +1,5 @@
@echo off
REM Serene Pub Application Launcher
REM This launches the main run.cmd script
cd /d "%~dp0"
call run.cmd

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,24 +1,95 @@
@echo off
setlocal
set DIR=%~dp0
set NODE_ENV=production
set NODE_BIN=%DIR%node.exe
set NODE_URL=https://nodejs.org/dist/v20.13.1/node-v20.13.1-win-x64.zip
set NODE_ARCHIVE=node-archive.win.zip
set NODE_DIR=node-v20.13.1-win-x64
set NODE_BIN_PATH=%NODE_DIR%\node.exe
REM Serene Pub Application Launcher
REM Licensed under AGPL-3.0 - See LICENSE file
REM Source: https://github.com/doolijb/serene-pub
REM Download Node.js if needed
setlocal enabledelayedexpansion
REM === Configuration ===
set NODE_ENV=production
set DIR=%~dp0
set DIR=%DIR:~0,-1%
set NODE_BIN=%DIR%\node.exe
set APP_MAIN=%DIR%\build\index.js
REM === Load Environment Variables ===
set ENV_FILE=%DIR%\.env
if exist "%ENV_FILE%" (
echo Loading configuration from .env file...
for /f "usebackq tokens=1* delims==" %%a in ("%ENV_FILE%") do (
set "line=%%a"
if not "!line:~0,1!"=="#" (
if not "%%a"=="" if not "%%b"=="" (
set "%%a=%%b"
)
)
)
)
echo ========================================
echo Serene Pub - AI Chat Application
echo https://github.com/doolijb/serene-pub
echo ========================================
echo.
REM === Verify Node.js Runtime ===
if not exist "%NODE_BIN%" (
echo Downloading Node.js...
powershell -Command "Invoke-WebRequest -Uri %NODE_URL% -OutFile %NODE_ARCHIVE%"
powershell -Command "Expand-Archive -Path %NODE_ARCHIVE% -DestinationPath ."
copy %NODE_BIN_PATH% %NODE_BIN%
rmdir /s /q %NODE_DIR%
del %NODE_ARCHIVE%
echo ERROR: Node.js runtime not found at %NODE_BIN%
echo Please ensure all application files are present in this directory.
goto :Error
)
REM === Verify Application Files ===
if not exist "%APP_MAIN%" (
echo ERROR: Application file not found at %APP_MAIN%
echo Please ensure all application files are present in this directory.
goto :Error
)
echo Starting Serene Pub...
"%NODE_BIN%" "%DIR%build\index.js" %*
echo.
echo The application will be available at:
echo - http://localhost:3000
echo - http://127.0.0.1:3000
echo.
echo Press Ctrl+C to stop the application.
echo ========================================
echo.
pause
REM === Start Application ===
echo Starting application...
"%NODE_BIN%" "%APP_MAIN%"
REM === Application Exit Handling ===
set EXIT_CODE=%ERRORLEVEL%
echo.
echo ========================================
if %EXIT_CODE% equ 0 (
echo Serene Pub stopped normally.
) else (
echo Serene Pub exited with code: %EXIT_CODE%
echo Check the output above for any error messages.
echo.
echo Common issues:
echo - Missing or corrupted application files
echo - Port 3000 already in use by another application
echo - Insufficient permissions
echo - Antivirus software blocking the application
)
echo.
goto :End
:Error
echo.
echo ========================================
echo Setup failed. Please ensure:
echo 1. All application files are present
echo 2. Node.js runtime (node.exe) is included
echo 3. Visit https://github.com/doolijb/serene-pub for help
echo ========================================
echo.
:End
echo Press any key to exit...
pause >nul
exit /b %EXIT_CODE%

View file

@ -0,0 +1,5 @@
CREATE TABLE "system_settings" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "system_settings_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"ollama_manager_enabled" boolean DEFAULT false NOT NULL,
"ollama_base_url" text DEFAULT 'http://localhost:11434/' NOT NULL
);

View file

@ -0,0 +1,10 @@
ALTER TABLE "characters" ALTER COLUMN "example_dialogues" SET DATA TYPE json USING
CASE
WHEN "example_dialogues" IS NULL OR trim("example_dialogues") = '' THEN '[]'::json
WHEN trim("example_dialogues") ~ '^\[.*\]$' OR trim("example_dialogues") ~ '^\{.*\}$' THEN
trim("example_dialogues")::json
ELSE '[]'::json
END;--> statement-breakpoint
ALTER TABLE "characters" ALTER COLUMN "example_dialogues" SET DEFAULT '[]'::json;--> statement-breakpoint
ALTER TABLE "characters" ALTER COLUMN "example_dialogues" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "tags" ADD COLUMN "color_preset" text DEFAULT 'preset-filled-primary-500' NOT NULL;

View file

@ -0,0 +1,3 @@
ALTER TABLE "system_settings" ADD COLUMN "show_all_character_fields" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "system_settings" ADD COLUMN "enable_easy_character_creation" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "system_settings" ADD COLUMN "enable_easy_persona_creation" boolean DEFAULT true NOT NULL;

View file

@ -0,0 +1,21 @@
CREATE TABLE "chat_tags" (
"chat_id" integer NOT NULL,
"tag_id" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lorebook_tags" (
"lorebook_id" integer NOT NULL,
"tag_id" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "persona_tags" (
"persona_id" integer NOT NULL,
"tag_id" integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE "chat_tags" ADD CONSTRAINT "chat_tags_chat_id_chats_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."chats"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_tags" ADD CONSTRAINT "chat_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lorebook_tags" ADD CONSTRAINT "lorebook_tags_lorebook_id_lorebooks_id_fk" FOREIGN KEY ("lorebook_id") REFERENCES "public"."lorebooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lorebook_tags" ADD CONSTRAINT "lorebook_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "persona_tags" ADD CONSTRAINT "persona_tags_persona_id_personas_id_fk" FOREIGN KEY ("persona_id") REFERENCES "public"."personas"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "persona_tags" ADD CONSTRAINT "persona_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,38 @@
-- Custom SQL migration file, put your code below! --
-- Convert {char:#} to {{char:#}} in lorebook_bindings.binding
UPDATE lorebook_bindings
SET binding = REGEXP_REPLACE(binding, '\{char:([0-9]+)\}', '{{char:\1}}', 'g')
WHERE binding ~ '\{char:[0-9]+\}';
--> statement-breakpoint
-- Convert {char:#} to {{char:#}} in content fields
-- Update history_entries.content
UPDATE history_entries
SET content = REGEXP_REPLACE(content, '\{char:([0-9]+)\}', '{{char:\1}}', 'g')
WHERE content ~ '\{char:[0-9]+\}';
--> statement-breakpoint
-- Update character_lore_entries.content
UPDATE character_lore_entries
SET content = REGEXP_REPLACE(content, '\{char:([0-9]+)\}', '{{char:\1}}', 'g')
WHERE content ~ '\{char:[0-9]+\}';
--> statement-breakpoint
-- Update world_lore_entries.content
UPDATE world_lore_entries
SET content = REGEXP_REPLACE(content, '\{char:([0-9]+)\}', '{{char:\1}}', 'g')
WHERE content ~ '\{char:[0-9]+\}';
--> statement-breakpoint
-- Convert single-brace legacy tags to double-brace
-- Convert {user} to {{user}} in content fields
UPDATE history_entries
SET content = REGEXP_REPLACE(content, '\{(user|char|persona|character)\}', '{{\1}}', 'g')
WHERE content ~ '\{(user|char|persona|character)\}';
--> statement-breakpoint
-- Update character_lore_entries.content for legacy tags
UPDATE character_lore_entries
SET content = REGEXP_REPLACE(content, '\{(user|char|persona|character)\}', '{{\1}}', 'g')
WHERE content ~ '\{(user|char|persona|character)\}';
--> statement-breakpoint
-- Update world_lore_entries.content for legacy tags
UPDATE world_lore_entries
SET content = REGEXP_REPLACE(content, '\{(user|char|persona|character)\}', '{{\1}}', 'g')
WHERE content ~ '\{(user|char|persona|character)\}';

View file

@ -0,0 +1 @@
-- Custom SQL migration file, put your code below! --

View file

@ -0,0 +1 @@
ALTER TABLE "chat_characters" ADD COLUMN "visibility" text DEFAULT 'visible' NOT NULL;

View file

@ -0,0 +1 @@
ALTER TABLE "system_settings" ADD COLUMN "show_home_page_banner" boolean DEFAULT true;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,62 @@
"when": 1751366812681,
"tag": "0002_awesome_thena",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1751942961856,
"tag": "0003_past_blue_marvel",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1753672569664,
"tag": "0004_nostalgic_blue_marvel",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1753830037288,
"tag": "0005_cold_big_bertha",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1753858544609,
"tag": "0006_tiny_power_man",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1754715423523,
"tag": "0007_unusual_amphibian",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1754721264282,
"tag": "0008_regular_fat_cobra",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1754857930869,
"tag": "0009_petite_mimic",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1755411731603,
"tag": "0010_calm_nightshade",
"breakpoints": true
}
]
}

View file

@ -1,7 +1,7 @@
{
"name": "serene-pub",
"private": true,
"version": "0.3.2",
"version": "0.4.1-alpha",
"type": "module",
"license": "AGPL-3.0",
"bin": "build/index.js",
@ -30,25 +30,25 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check .",
"db:generate": "drizzle-kit generate --config ./src/lib/server/db/drizzle.config.ts",
"db:migrate": "drizzle-kit migrate --config ./src/lib/server/db/drizzle.config.ts",
"db:studio": "drizzle-kit studio --config ./src/lib/server/db/drizzle.config.ts",
"db:generate": "node scripts/check-db-lock.js drizzle-kit generate --config ./src/lib/server/db/drizzle.config.ts",
"db:migrate": "node scripts/check-db-lock.js drizzle-kit migrate --config ./src/lib/server/db/drizzle.config.ts",
"db:studio": "node scripts/check-db-lock.js drizzle-kit studio --config ./src/lib/server/db/drizzle.config.ts",
"bundle": "node scripts/bundle-dist.js",
"dist": "npm run build && npm run bundle"
"dist": "npm run build && npm run bundle",
"create-executables": "node scripts/create-executables.js"
},
"devDependencies": {
"@enviro/metadata": "^1.5.1",
"@fontsource/fira-mono": "^5.0.0",
"@neoconfetti/svelte": "^2.0.0",
"@skeletonlabs/skeleton": "^3.1.3",
"@skeletonlabs/skeleton-svelte": "^1.2.3",
"@skeletonlabs/skeleton": "^3.1.4",
"@skeletonlabs/skeleton-svelte": "^1.2.4",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@sveltejs/kit": "^2.22.2",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/better-sqlite3": "^7.6.12",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.17",
"@types/node": "^20",
@ -64,7 +64,7 @@
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.25.0",
"svelte": "^5.35.4",
"svelte-check": "^4.0.0",
"sveltekit-sse": "^0.13.19",
"tailwindcss": "^4.0.0",
@ -83,7 +83,6 @@
"@tiptap/core": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/starter-kit": "^2.22.3",
"better-sqlite3": "^11.8.0",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.40.0",
"file-type": "^21.0.0",
@ -100,6 +99,7 @@
"pg": "^8.16.3",
"svelte-dnd-action": "^0.9.61",
"sveltekit-io": "^1.0.12",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"zod": "^3.25.76"
}
}

View file

@ -14,7 +14,7 @@ const version = pkg.version
const distDir = path.resolve(__dirname, "../dist")
const buildDir = path.resolve(__dirname, "../build")
const staticDir = path.resolve(__dirname, "../static")
const filesToCopy = ["LICENSE", "README.md", "NOTICE.md"]
const filesToCopy = ["LICENSE", "README.md", "NOTICE.md", "KEYBINDINGS.md"]
function copyRecursive(src, dest) {
if (!fs.existsSync(src)) return
@ -209,20 +209,20 @@ const targets = [
{ name: "macos-x64", platform: "darwin", arch: "x64" },
{ name: "macos-arm64", platform: "darwin", arch: "arm64" },
{ name: "windows-x64", platform: "win32", arch: "x64" },
{ name: "windows-arm64", platform: "win32", arch: "arm64" },
{ name: "windows-arm64", platform: "win32", arch: "arm64" }
]
// Accept a single target as a command-line argument
const argTarget = process.argv[2]
if (!argTarget) {
console.error("Usage: node bundle-dist.js <target>")
console.error("Valid targets:", targets.map(t => t.name).join(", "))
console.error("Valid targets:", targets.map((t) => t.name).join(", "))
process.exit(1)
}
const target = targets.find(t => t.name === argTarget)
const target = targets.find((t) => t.name === argTarget)
if (!target) {
console.error(`Invalid target: ${argTarget}`)
console.error("Valid targets:", targets.map(t => t.name).join(", "))
console.error("Valid targets:", targets.map((t) => t.name).join(", "))
process.exit(1)
}
@ -244,21 +244,24 @@ if (!target) {
}
// 2. Create dist bundle
const outDir = path.join(distDir, `serene-pub-${version}-${target.name}`)
const outDir = path.join(
distDir,
`serene-pub-${version}-${target.name}`
)
if (fs.existsSync(outDir))
fs.rmSync(outDir, { recursive: true, force: true })
fs.mkdirSync(outDir, { recursive: true })
// Copy build and static
copyRecursive(buildDir, path.join(outDir, "build"))
copyRecursive(staticDir, path.join(outDir, "static"))
// Copy node_modules (assuming it's already prepared for this target)
copyRecursive(
path.resolve(__dirname, "../node_modules"),
path.join(outDir, "node_modules")
)
// Copy LICENSE, README, etc.
for (const file of filesToCopy) {
if (fs.existsSync(path.resolve(__dirname, "..", file))) {
@ -268,7 +271,7 @@ if (!target) {
)
}
}
// Copy platform-specific instructions
const instrFile = path.resolve(
__dirname,
@ -277,23 +280,80 @@ if (!target) {
if (fs.existsSync(instrFile)) {
fs.copyFileSync(instrFile, path.join(outDir, "INSTRUCTIONS.txt"))
}
// Copy Node.js binary for the target platform
const isWindows = target.platform === "win32"
const nodeSrcName = isWindows ? "node.exe" : "node"
const nodeSrcPath = path.resolve(__dirname, "..", nodeSrcName)
const nodeDestPath = path.join(outDir, nodeSrcName)
if (fs.existsSync(nodeSrcPath)) {
fs.copyFileSync(nodeSrcPath, nodeDestPath)
if (!isWindows) {
fs.chmodSync(nodeDestPath, 0o755)
}
console.log(`Copied Node.js binary: ${nodeSrcName}`)
} else {
console.warn(`Warning: Node.js binary not found at ${nodeSrcPath}`)
}
// Copy all run files from dist-assets/<os>/
const runFiles = fs
.readdirSync(path.resolve(__dirname, `../dist-assets/${target.name.split("-")[0]}`))
.readdirSync(
path.resolve(
__dirname,
`../dist-assets/${target.name.split("-")[0]}`
)
)
.filter((f) => f.startsWith("run."))
for (const runFile of runFiles) {
const src = path.resolve(__dirname, `../dist-assets/${target.name.split("-")[0]}/${runFile}`)
const src = path.resolve(
__dirname,
`../dist-assets/${target.name.split("-")[0]}/${runFile}`
)
const dest = path.join(outDir, runFile)
fs.copyFileSync(src, dest)
if (target.platform !== "win32" && runFile.endsWith(".sh")) {
fs.chmodSync(dest, 0o755)
}
}
// Copy platform-specific executables and icons
const platformDir = path.resolve(__dirname, `../dist-assets/${target.name.split("-")[0]}`)
const platformFiles = fs.readdirSync(platformDir)
for (const file of platformFiles) {
const srcPath = path.join(platformDir, file)
const destPath = path.join(outDir, file)
// Skip run files (already copied above) and INSTRUCTIONS.txt (copied separately)
if (file.startsWith("run.") || file === "INSTRUCTIONS.txt") {
continue
}
if (fs.lstatSync(srcPath).isDirectory()) {
// Copy directories recursively (like .app bundles)
copyRecursive(srcPath, destPath)
console.log(`Copied directory: ${file}`)
} else {
// Copy individual files
fs.copyFileSync(srcPath, destPath)
// Make executables executable on Unix platforms
if (target.platform !== "win32" &&
(file === "Serene Pub" || file.endsWith(".desktop"))) {
fs.chmodSync(destPath, 0o755)
}
console.log(`Copied file: ${file}`)
}
}
// Copy drizzle migrations folder
copyRecursive(path.resolve(__dirname, '../drizzle'), path.join(outDir, 'drizzle'))
copyRecursive(
path.resolve(__dirname, "../drizzle"),
path.join(outDir, "drizzle")
)
// Write minimal package.json
fs.writeFileSync(
path.join(outDir, "package.json"),

254
scripts/check-db-lock.js Normal file
View file

@ -0,0 +1,254 @@
#!/usr/bin/env node
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { spawn } from "child_process"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Get the project root directory
const projectRoot = path.resolve(__dirname, "..")
// Load environment variables from .env file if it exists
function loadEnvFile() {
const envPath = path.join(projectRoot, ".env")
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, "utf-8")
const envLines = envContent.split("\n")
for (const line of envLines) {
const trimmedLine = line.trim()
if (trimmedLine && !trimmedLine.startsWith("#")) {
const [key, ...valueParts] = trimmedLine.split("=")
if (key && valueParts.length > 0) {
const value = valueParts
.join("=")
.replace(/^["']|["']$/g, "")
process.env[key.trim()] = value
}
}
}
}
}
// Load .env before doing anything else
loadEnvFile()
// Lock configuration
const DEFAULT_LOCK_LENGTH = 5000 // 5 seconds in milliseconds
let lockUpdateInterval = null
let metaPath = null
let lockReleased = false
// Get data directory with the same logic as the utility function
function getDataDirectory() {
// Check for custom data directory from environment
const envDataDir = process.env.SERENE_PUB_DATA_DIR
if (envDataDir) {
return path.join(envDataDir, "data")
}
// Check for CI environment
const isCI = process.env.CI === "true"
if (isCI) {
return "~/SerenePubData"
}
// Fallback to envPaths logic - we need to import it dynamically
try {
// Simple fallback calculation without importing envPaths
// This mimics what envPaths would return for most systems
const os = process.platform
const home =
process.env.HOME || process.env.USERPROFILE || process.env.HOMEPATH
let dataPath
if (os === "darwin") {
dataPath = path.join(
home,
"Library",
"Application Support",
"SerenePub"
)
} else if (os === "win32") {
dataPath = path.join(
process.env.APPDATA || path.join(home, "AppData", "Roaming"),
"SerenePub"
)
} else {
// Linux and others
const xdgDataHome =
process.env.XDG_DATA_HOME || path.join(home, ".local", "share")
dataPath = path.join(xdgDataHome, "SerenePub")
}
return path.join(dataPath, "data")
} catch (error) {
console.error("Failed to determine data directory:", error.message)
process.exit(1)
}
}
function updateDatabaseLock() {
try {
// Read current meta.json
let meta = { version: "0.0.0" }
if (fs.existsSync(metaPath)) {
meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"))
}
// Update lock
meta.lock = {
timestamp: Date.now(),
lockLength: DEFAULT_LOCK_LENGTH
}
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2))
} catch (error) {
console.error("Failed to update database lock:", error.message)
}
}
function startLockUpdates() {
// Update lock immediately
updateDatabaseLock()
console.log("Database locked for db operation.")
// Set up interval to update lock every few seconds
lockUpdateInterval = setInterval(() => {
updateDatabaseLock()
}, DEFAULT_LOCK_LENGTH - 1000) // Update 1 second before lock expires
}
function stopLockUpdates() {
if (lockReleased) {
return // Already cleaned up
}
lockReleased = true
if (lockUpdateInterval) {
clearInterval(lockUpdateInterval)
lockUpdateInterval = null
}
// Clear the lock
try {
if (fs.existsSync(metaPath)) {
const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"))
delete meta.lock
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2))
console.log("\nDatabase lock released.")
}
} catch (error) {
console.error("Failed to clear database lock:", error.message)
}
}
async function checkDatabaseLock() {
try {
const dataDir = getDataDirectory()
metaPath = path.join(dataDir, "meta.json")
// Check if meta.json exists
if (!fs.existsSync(metaPath)) {
console.log("meta.json does not exist. Continuing...")
return
}
// Read meta.json
let meta
try {
meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"))
} catch (error) {
console.error("Failed to read meta.json:", error.message)
process.exit(1)
}
// Check if lock exists
if (!meta.lock) {
console.log("No database lock found. Continuing...")
return
}
const currentTime = Date.now()
const lockExpiry = meta.lock.timestamp + meta.lock.lockLength
if (currentTime < lockExpiry) {
// Lock is still active
const remainingTime = Math.ceil((lockExpiry - currentTime) / 1000)
console.error(
`Database is currently locked. Lock expires in ${remainingTime} seconds.`
)
console.error(
"Please wait for the lock to expire or stop the running application."
)
process.exit(1)
} else {
// Lock is stale
console.log("Found stale database lock. Continuing...")
}
} catch (error) {
console.error("Error checking database lock:", error.message)
process.exit(1)
}
}
async function runWithLock() {
// Get command line arguments (everything after the script name)
const args = process.argv.slice(2)
if (args.length === 0) {
console.error("No command provided to run with lock")
process.exit(1)
}
try {
// Check for existing lock first
await checkDatabaseLock()
// Start maintaining our lock
startLockUpdates()
// Set up cleanup handlers
process.on("exit", stopLockUpdates)
process.on("SIGINT", () => {
stopLockUpdates()
process.exit(0)
})
process.on("SIGTERM", () => {
stopLockUpdates()
process.exit(0)
})
// Run the actual command
const command = args[0]
const commandArgs = args.slice(1)
console.log(`Running: ${command} ${commandArgs.join(" ")}`)
const child = spawn(command, commandArgs, {
stdio: "inherit",
shell: true
})
child.on("close", (code) => {
stopLockUpdates()
process.exit(code)
})
child.on("error", (error) => {
console.error("Failed to start command:", error.message)
stopLockUpdates()
process.exit(1)
})
} catch (error) {
console.error("Error running command with lock:", error.message)
stopLockUpdates()
process.exit(1)
}
}
// Run the command with lock
runWithLock()

View file

@ -0,0 +1,239 @@
#!/usr/bin/env node
/**
* Script to create platform-specific executables with custom icons
* This script generates clickable applications for Windows, Linux, and macOS
*/
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const platforms = {
windows: {
name: 'Serene Pub.exe',
launcher: 'Serene Pub.bat',
icon: 'favicon.ico',
template: 'serene-pub-launcher.exe'
},
linux: {
name: 'Serene Pub',
executable: 'Serene Pub',
icon: 'favicon.png',
desktop: 'Serene Pub.desktop'
},
macos: {
name: 'Serene Pub.app',
icon: 'favicon.icns',
bundle: 'Serene Pub.app'
}
}
async function createExecutables() {
console.log('🚀 Creating platform-specific executables...')
// Ensure output directories exist
const distDir = path.join(__dirname, '..', 'dist-assets')
const staticDir = path.join(__dirname, '..', 'static')
const faviconSource = path.join(staticDir, 'favicon.png')
// Check if favicon exists
if (!fs.existsSync(faviconSource)) {
console.error('❌ favicon.png not found in static directory')
return
}
for (const [platform, config] of Object.entries(platforms)) {
const platformDir = path.join(distDir, platform)
if (!fs.existsSync(platformDir)) {
console.log(`📁 Creating directory: ${platformDir}`)
fs.mkdirSync(platformDir, { recursive: true })
}
// Copy favicon to platform directory
const faviconDest = path.join(platformDir, 'favicon.png')
fs.copyFileSync(faviconSource, faviconDest)
console.log(`📎 Copied favicon to ${platform}`)
console.log(`🔧 Processing ${platform}...`)
switch (platform) {
case 'windows':
await createWindowsExecutable(platformDir, config)
break
case 'linux':
await createLinuxExecutable(platformDir, config)
break
case 'macos':
await createMacOSExecutable(platformDir, config)
break
}
}
console.log('✅ All executables created successfully!')
console.log('')
console.log('📋 Next steps:')
console.log(' Windows: Convert "Serene Pub.bat" to "Serene Pub.exe" with custom icon')
console.log(' Linux: Use "Serene Pub" executable or "Serene Pub.desktop" file')
console.log(' macOS: Convert favicon.png to .icns and place in app bundle')
}
async function createWindowsExecutable(platformDir, config) {
// Create a simple batch wrapper that launches the existing run.cmd script
const launcherBat = path.join(platformDir, 'Serene Pub.bat')
const content = `@echo off
REM Serene Pub Application Launcher
REM This launches the main run.cmd script
cd /d "%~dp0"
call run.cmd
`
fs.writeFileSync(launcherBat, content)
// We'll need to use a tool like ResourceHacker or create a proper .exe
// For now, create instructions for manual icon setting
const iconInstructions = path.join(platformDir, 'ICON_SETUP.txt')
const instructions = `To set up the application icon:
1. Convert favicon.png to favicon.ico using an online converter
2. Use Resource Hacker (http://www.angusj.com/resourcehacker/) to:
- Open "Serene Pub Launcher.bat"
- Add the favicon.ico as the application icon
- Save as "Serene Pub.exe"
Or use a batch-to-exe converter that supports custom icons.
`
fs.writeFileSync(iconInstructions, instructions)
console.log(` ✓ Windows launcher created: ${launcherBat}`)
}
async function createLinuxExecutable(platformDir, config) {
// Create desktop entry file that launches the existing run.sh script
const desktopFile = path.join(platformDir, config.desktop)
const desktopContent = `[Desktop Entry]
Version=1.0
Type=Application
Name=Serene Pub
Comment=AI Chat Application
Exec=${platformDir}/run.sh
Icon=${platformDir}/favicon.png
Terminal=false
Categories=Network;Chat;
StartupNotify=true
Path=${platformDir}
`
fs.writeFileSync(desktopFile, desktopContent)
// Make the desktop file executable
try {
execSync(`chmod +x "${desktopFile}"`)
console.log(` ✓ Linux desktop entry created: ${desktopFile}`)
} catch (error) {
console.log(` ⚠️ Desktop file created but chmod failed: ${desktopFile}`)
}
// Create a simple executable wrapper that calls the existing run.sh
const executableScript = path.join(platformDir, config.executable)
const scriptContent = `#!/bin/bash
# Serene Pub Application Launcher
# This launches the main run.sh script
DIR="$( cd "$( dirname "\${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd "$DIR"
exec ./run.sh
`
fs.writeFileSync(executableScript, scriptContent)
try {
execSync(`chmod +x "${executableScript}"`)
console.log(` ✓ Linux executable created: ${executableScript}`)
} catch (error) {
console.log(` ⚠️ Executable created but chmod failed: ${executableScript}`)
}
}
async function createMacOSExecutable(platformDir, config) {
// Create macOS .app bundle structure
const appBundle = path.join(platformDir, 'Serene Pub.app')
const contentsDir = path.join(appBundle, 'Contents')
const macOSDir = path.join(contentsDir, 'MacOS')
const resourcesDir = path.join(contentsDir, 'Resources')
// Create directories
fs.mkdirSync(appBundle, { recursive: true })
fs.mkdirSync(contentsDir, { recursive: true })
fs.mkdirSync(macOSDir, { recursive: true })
fs.mkdirSync(resourcesDir, { recursive: true })
// Create Info.plist
const infoPlist = path.join(contentsDir, 'Info.plist')
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>serene-pub</string>
<key>CFBundleIdentifier</key>
<string>com.doolijb.serene-pub</string>
<key>CFBundleName</key>
<string>Serene Pub</string>
<key>CFBundleVersion</key>
<string>0.4.1</string>
<key>CFBundleShortVersionString</key>
<string>0.4.1</string>
<key>CFBundleIconFile</key>
<string>favicon.icns</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
</dict>
</plist>
`
fs.writeFileSync(infoPlist, plistContent)
// Create executable script that launches the existing run.sh
const executableScript = path.join(macOSDir, 'serene-pub')
const scriptContent = `#!/bin/bash
# Serene Pub Application Launcher for macOS
# This launches the main run.sh script
APP_DIR="$( cd "$( dirname "\${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
BUNDLE_DIR="$( cd "$APP_DIR/../.." &> /dev/null && pwd )"
cd "$BUNDLE_DIR"
exec ./run.sh
`
fs.writeFileSync(executableScript, scriptContent)
try {
execSync(`chmod +x "${executableScript}"`)
console.log(` ✓ macOS app bundle created: ${appBundle}`)
} catch (error) {
console.log(` ⚠️ App bundle created but chmod failed: ${appBundle}`)
}
// Create instructions for icon conversion
const iconInstructions = path.join(platformDir, 'ICON_SETUP.txt')
const instructions = `To set up the application icon:
1. Convert favicon.png to favicon.icns using:
- sips command: sips -s format icns favicon.png --out Resources/favicon.icns
- Or use an online converter
- Or use Image2icon app
2. Place the favicon.icns file in: Serene Pub.app/Contents/Resources/
The .app bundle is ready to use after adding the icon.
`
fs.writeFileSync(iconInstructions, instructions)
}
// Run the script
createExecutables().catch(console.error)

View file

@ -1,52 +1,47 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import fs from "fs"
import path from "path"
const buildFile = 'build/index.js';
const buildFile = "build/index.js"
if (!fs.existsSync(buildFile)) {
console.log('Build file not found:', buildFile);
process.exit(1);
console.log("Build file not found:", buildFile)
process.exit(1)
}
console.log('🔧 Customizing server build output...');
console.log("🔧 Customizing server build output...")
let content = fs.readFileSync(buildFile, 'utf8');
// Debug: Check what patterns exist in the file
console.log('Looking for patterns in the file...');
console.log('Contains "Listening on file descriptor":', content.includes('Listening on file descriptor'));
console.log('Contains "Listening on ${path":', content.includes('Listening on ${path'));
let content = fs.readFileSync(buildFile, "utf8")
// Replace console.log messages
let replacements = 0;
let replacements = 0
const originalListeningFd = content;
const originalListeningFd = content
content = content.replace(
/console\.log\(`Listening on file descriptor/g,
'console.log(`🚀 Serene Pub listening on file descriptor'
);
/console\.log\(`Listening on file descriptor/g,
"console.log(`🚀 Serene Pub listening on file descriptor"
)
if (content !== originalListeningFd) {
replacements++;
console.log('✅ Replaced file descriptor listening message');
replacements++
console.log("✅ Replaced file descriptor listening message")
}
const originalListeningPath = content;
const originalListeningPath = content
content = content.replace(
/console\.log\(`Listening on \$\{path/g,
'console.log(`🚀 Serene Pub listening on ${path'
);
/console\.log\(`Listening on \$\{path/g,
"console.log(`🚀 Serene Pub listening on ${path"
)
if (content !== originalListeningPath) {
replacements++;
console.log('✅ Replaced path listening message');
replacements++
console.log("✅ Replaced path listening message")
}
console.log(`Applied ${replacements} basic replacements`);
console.log(`Applied ${replacements} basic replacements`)
// Add launch message after the listening message
content = content.replace(
/console\.log\(`🚀 Serene Pub listening on \$\{path \|\| `http:\/\/\$\{host\}:\$\{port\}`\}`\);/g,
`console.log(\`🚀 Serene Pub listening on \${path || \`http://\${host}:\${port}\`}\`);
/console\.log\(`🚀 Serene Pub listening on \$\{path \|\| `http:\/\/\$\{host\}:\$\{port\}`\}`\);/g,
`console.log(\`🚀 Serene Pub listening on \${path || \`http://\${host}:\${port}\`}\`);
if (!path) {
console.log(\`\`);
console.log(\` \`);
@ -100,24 +95,27 @@ content = content.replace(
console.log(\`\`);
// Auto-open browser if SERENE_AUTO_OPEN is not disabled
if (process.env.SERENE_AUTO_OPEN !== 'false') {
if (process.env.SERENE_AUTO_OPEN !== '1' && process.env.SERENE_AUTO_OPEN !== 'true') {
setTimeout(() => {
import('open').then(({ default: open }) => {
open(\`http://localhost:\${port}\`);
console.log(\`🚀 Opening Serene Pub in your default browser...\`);
}).catch(() => {
// Silently fail if 'open' package is not available
}).catch((err) => {
console.warn(\`⚠️ Could not auto-open browser: \${err.message}\`);
console.log(\`💡 You can manually open http://localhost:\${port} in your browser\`);
});
}, 1000);
} else {
console.log(\` Auto-open browser disabled (SERENE_AUTO_OPEN=\${process.env.SERENE_AUTO_OPEN})\`);
}
}`
);
)
// Add shutdown message - fix the function call pattern
content = content.replace(
/function graceful_shutdown\(reason\) \{/g,
'function graceful_shutdown(reason) {\n\tconsole.log(`👋 Serene Pub shutting down (${reason})`);'
);
/function graceful_shutdown\(reason\) \{/g,
"function graceful_shutdown(reason) {\n\tconsole.log(`👋 Serene Pub shutting down (${reason})`);"
)
fs.writeFileSync(buildFile, content);
console.log('✅ Server build output customized successfully!');
fs.writeFileSync(buildFile, content)
console.log("✅ Server build output customized successfully!")

View file

@ -43,7 +43,7 @@
}
.rendered-chat-message-content p {
word-wrap:break-word;
word-wrap: break-word;
}
.rendered-chat-message-content p + p {
@ -64,13 +64,13 @@
.tiptap.ProseMirror {
/* padding: 0.33em; */
@apply rounded-lg py-2 px-2 min-h-[6em];
@apply min-h-[6em] rounded-lg px-2 py-2;
}
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}

345
src/app.d.ts vendored
View file

@ -6,6 +6,7 @@ import type { Schema } from "inspector/promises"
import type { P } from "ollama/dist/shared/ollama.d792a03f.mjs"
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions/completions"
import { FileAcceptDetails } from "../node_modules/@zag-js/file-upload/dist/index.d"
import type { ListResponse } from "ollama"
// for information about these interfaces
declare global {
@ -50,8 +51,11 @@ declare global {
digest: {
characterId?: number
personaId?: number
chatId?: number
chatPersonaId?: number
chatCharacterId?: number
lorebookId?: number
tutorial?: boolean
}
}
@ -71,6 +75,17 @@ declare global {
theme: string
}
interface SystemSettingsCtx {
settings: {
ollamaManagerEnabled: boolean
ollamaManagerBaseUrl: string
showAllCharacterFields: boolean
enableEasyCharacterCreation: boolean
enableEasyPersonaCreation: boolean
showHomePageBanner: boolean
}
}
// Model select and insert
type SelectUser = typeof schema.users.$inferSelect
type InsertUser = typeof schema.users.$inferInsert
@ -114,6 +129,31 @@ declare global {
type InsertLorebookBinding = typeof schema.lorebookBindings.$inferInsert
namespace Sockets {
namespace SystemSettings {
interface Call {}
interface Response {
systemSettings: {
ollamaManagerEnabled: boolean
ollamaManagerBaseUrl: string
showAllCharacterFields: boolean
enableEasyCharacterCreation: boolean
enableEasyPersonaCreation: boolean
showHomePageBanner: boolean
}
}
}
namespace Error {
interface Response {
error: string
description?: string
}
}
namespace Success {
interface Response {
title: string
description?: string
}
}
namespace SamplingConfig {
interface Call {
id: number
@ -322,6 +362,202 @@ declare global {
error: string | null
}
}
// --- OLLAMA ---
namespace OllamaSetBaseUrl {
interface Call {
baseUrl: string
}
interface Response {
success: boolean
}
}
namespace OllamaModelsList {
interface Call {}
interface Response {
models: any[]
}
}
namespace OllamaDeleteModel {
interface Call {
modelName: string
}
interface Response {
success: boolean
}
}
namespace OllamaConnectModel {
interface Call {
modelName: string
}
interface Response {
success: boolean
}
}
namespace OllamaListRunningModels {
interface Call {}
interface Response {
models: ListResponse["models"]
}
}
namespace OllamaPullModel {
interface Call {
modelName: string
}
interface Response {
success: boolean
error?: string
progress?: any
}
}
namespace OllamaPullProgress {
interface Response {
downloadingQuants: {
[key: string]: {
modelName: string
status: string
isDone: boolean
files: {
[key: string]: { total: number; completed: number }
}
}
}
}
}
namespace OllamaStopModel {
interface Call {
modelName: string
}
interface Response {
success: boolean
}
}
namespace OllamaCancelPull {
interface Call {
modelName: string
}
interface Response {
success: boolean
modelName?: string
error?: string
}
}
namespace OllamaVersion {
interface Call {}
interface Response {
version?: string
}
}
namespace OllamaIsUpdateAvailable {
interface Call {}
interface Response {
updateAvailable: boolean
currentVersion?: string
latestVersion?: string
error?: string
}
}
namespace OllamaSearchAvailableModels {
interface Call {
search: string
source: string
}
interface Response {
models: Array<{
name: string
description?: string
size?: string
tags?: string[]
popular?: boolean
url?: string
downloads?: number
updatedAtStr?: string
createdAt?: Date
likes?: number
trendingScore?: number
pullOptions?: { label: string; pull: string }[]
}>
error?: string
}
}
namespace OllamaRecommendedModels {
interface Call {}
interface Response {
models: Array<{
name: string
pull: string
size: number
recommended_vram: number
details: {
parameter_size: string
quantization_level: string
modified_at: string
description: string
}
}>
error?: string
}
}
namespace OllamaGetDownloadProgress {
interface Call {}
interface Response {
// This endpoint doesn't return a direct response,
// it triggers ollamaPullProgress events for each active download
}
}
namespace OllamaClearDownloadHistory {
interface Call {}
interface Response {
success: boolean
}
}
// --- APP SETTINGS ---
namespace UpdateOllamaManagerEnabled {
interface Call {
enabled: boolean
}
interface Response {
success: boolean
enabled?: boolean
}
}
namespace UpdateShowAllCharacterFields {
interface Call {
enabled: boolean
}
interface Response {
success: boolean
enabled?: boolean
}
}
namespace UpdateEasyCharacterCreation {
interface Call {
enabled: boolean
}
interface Response {
success: boolean
enabled?: boolean
}
}
namespace UpdateEasyPersonaCreation {
interface Call {
enabled: boolean
}
interface Response {
success: boolean
enabled?: boolean
}
}
namespace UpdateShowHomePageBanner {
interface Call {
enabled: boolean
}
interface Response {
success: boolean
enabled?: boolean
}
}
// --- WEIGHTS ---
namespace DeleteSamplingConfig {
interface Call {
@ -398,6 +634,8 @@ declare global {
namespace Chat {
interface Call {
id: number
limit?: number
offset?: number
}
interface Response {
chat: SelectChat & {
@ -407,6 +645,10 @@ declare global {
{ character: SelectCharacter }[]
chatMessages: SelectChatMessage[]
}
pagination?: {
total: number
hasMore: boolean
}
}
}
namespace ChatMessage {
@ -568,7 +810,9 @@ declare global {
userId?: number
}
interface Response {
lorebookList: SelectLorebook[]
lorebookList: (SelectLorebook & {
tags: string[]
})[]
}
}
@ -583,6 +827,7 @@ declare global {
characterLoreEntries: SelectCharacterLoreEntry[]
historyEntries: SelectHistoryEntry[]
lorebookBindings: SelectLorebookBinding[]
tags: string[]
}
}
}
@ -591,6 +836,7 @@ declare global {
namespace CreateLorebook {
interface Call {
name: string
tags?: string[]
}
interface Response {
lorebook: SelectLorebook
@ -689,9 +935,8 @@ declare global {
// Update Lorebook
namespace UpdateLorebook {
interface Call {
lorebook: {
id: number
name: string
lorebook: SelectLorebook & {
tags?: string[]
}
}
interface Response {
@ -857,6 +1102,19 @@ declare global {
isActive: boolean
}
}
// Update Chat Character Visibility
namespace UpdateChatCharacterVisibility {
interface Call {
chatId: number
characterId: number
visibility: string
}
interface Response {
chatId: number
characterId: number
visibility: string
}
}
namespace SetTheme {
interface Call {
theme: string
@ -864,6 +1122,81 @@ declare global {
}
interface Response {}
}
// TAGS
namespace TagsList {
interface Call {}
interface Response {
tagsList: SelectTag[]
}
}
namespace CreateTag {
interface Call {
tag: InsertTag
}
interface Response {
tag: SelectTag
}
}
namespace UpdateTag {
interface Call {
tag: SelectTag
}
interface Response {
tag: SelectTag
}
}
namespace DeleteTag {
interface Call {
id: number
}
interface Response {
id: number
}
}
namespace TagRelatedData {
interface Call {
tagId: number
}
interface Response {
characters: SelectCharacter[]
personas: SelectPersona[]
chats: SelectChat[]
}
}
namespace AddTagToCharacter {
interface Call {
characterId: number
tagId: number
}
interface Response {
characterId: number
tagId: number
}
}
namespace RemoveTagFromCharacter {
interface Call {
characterId: number
tagId: number
}
interface Response {
characterId: number
tagId: number
}
}
// --- USER ---
namespace User {
interface Call {}
interface Response {
user:
| (SelectUser & {
activeConnection: SelectConnection | null
activeSamplingConfig: SelectSamplingConfig | null
activeContextConfig: SelectContextConfig | null
activePromptConfig: SelectPromptConfig | null
})
| undefined
}
}
}
export interface CharaImportMetadata {
@ -917,8 +1250,8 @@ declare global {
nickname?: string
description: boolean
personality: boolean
wiBefore: boolean
wiAfter: boolean
exampleDialogue: boolean
postHistoryInstructions: boolean
postHistoryInstructions: boolean
}>
personas: Array<{

View file

@ -1 +0,0 @@

View file

@ -1,9 +1,6 @@
import { browser } from "$app/environment";
import type { Handle } from "@sveltejs/kit";
import { browser } from "$app/environment"
import type { Handle } from "@sveltejs/kit"
export const handle: Handle = async ({ event, resolve }) => {
if (!browser) {
console.log("New request:", event.request.url);
}
return await resolve(event);
};
return await resolve(event)
}

View file

@ -1,23 +1,24 @@
<script lang="ts">
import { Avatar } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import * as Icons from "@lucide/svelte"
interface Props {
char: Partial<SelectCharacter> | Partial<SelectPersona>
src?: string
}
interface Props {
char: Partial<SelectCharacter> | Partial<SelectPersona>
src?: string
}
let {
char = $bindable(),
src = $bindable()
}: Props = $props()
let { char = $bindable(), src = $bindable() }: Props = $props()
</script>
<Avatar
src={src ? src : char ? char.avatar || "" : ""}
size="w-[4em] h-[4em]"
imageClasses="object-cover"
name={char ? "nickname" in char && char.nickname ? char.nickname : char.name! : "Unknown"}
src={src ? src : char ? char.avatar || "" : ""}
size="w-[4em] h-[4em]"
imageClasses="object-cover"
name={char
? "nickname" in char && char.nickname
? char.nickname
: char.name!
: "Unknown"}
>
<Icons.User size={36} />
</Avatar>
<Icons.User size={36} />
</Avatar>

View file

@ -1,95 +1,119 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { getContext, onMount, onDestroy } from "svelte"
import * as Icons from "@lucide/svelte"
import { getContext, onMount, onDestroy } from "svelte"
let panelsCtx: PanelsCtx = $state(getContext("panelsCtx"))
let panelsCtx: PanelsCtx = $state(getContext("panelsCtx"))
// Prevent body scroll when mobile menu is open
$effect(() => {
if (panelsCtx.isMobileMenuOpen) {
document.body.style.overflow = "hidden"
} else {
document.body.style.overflow = ""
}
})
// Prevent body scroll when mobile menu is open
$effect(() => {
if (panelsCtx.isMobileMenuOpen) {
document.body.style.overflow = "hidden"
} else {
document.body.style.overflow = ""
}
})
// Close on Escape key
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && panelsCtx.isMobileMenuOpen) {
panelsCtx.isMobileMenuOpen = false
}
}
onMount(() => {
window.addEventListener("keydown", handleKeydown)
return () => window.removeEventListener("keydown", handleKeydown)
})
// Close on Escape key
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && panelsCtx.isMobileMenuOpen) {
panelsCtx.isMobileMenuOpen = false
}
}
onMount(() => {
window.addEventListener("keydown", handleKeydown)
return () => window.removeEventListener("keydown", handleKeydown)
})
</script>
<header class="w-full">
<div
class="bg-surface-100-900 bg-opacity-25 relative mx-auto flex w-full justify-between px-4 py-2 backdrop-blur"
>
<header class="w-full" role="banner">
<div
class="bg-surface-100-900 bg-opacity-25 relative mx-auto flex w-full justify-between px-4 py-2 backdrop-blur"
>
<!-- Desktop left nav -->
<nav
class="hidden flex-1 justify-start gap-2 lg:flex"
aria-label="Left navigation"
role="navigation"
>
{#each Object.entries(panelsCtx.leftNav) as [key, item]}
{@const isOpen = panelsCtx.leftPanel === key}
<button
title={item.title}
onclick={() => panelsCtx.openPanel({ key })}
aria-pressed={isOpen}
aria-label="Open {item.title} panel"
type="button"
>
<item.icon
class="{isOpen
? 'text-primary-800-200'
: ''} hover:text-primary-500 h-5 w-5 transition-colors"
aria-hidden="true"
/>
</button>
{/each}
</nav>
<!-- Desktop left nav -->
<div class="hidden flex-1 justify-start gap-2 md:flex">
{#each Object.entries(panelsCtx.leftNav) as [key, item]}
{@const isOpen = panelsCtx.leftPanel === key}
<button
title={item.title}
onclick={() => panelsCtx.openPanel({key})}
>
<item.icon
class="{isOpen ? "text-primary-800-200" : ""} h-5 w-5 hover:text-primary-500 transition-colors"
/>
</button>
{/each}
</div>
<!-- Title (centered absolutely for desktop) -->
<div
class="pointer-events-none ml-2 flex w-auto flex-0 justify-center md:absolute md:top-1/2 md:left-1/2 md:ml-0 md:w-auto md:-translate-x-1/2 md:-translate-y-1/2"
>
<a
class="text-foreground funnel-display pointer-events-auto text-xl font-bold tracking-tight whitespace-nowrap"
href="/"
aria-label="Serene Pub - Home"
>
Serene Pub
</a>
</div>
<!-- Title (centered absolutely for desktop) -->
<div
class="flex w-auto flex-0 justify-center ml-2 md:ml-0 md:absolute md:top-1/2 md:left-1/2 md:w-auto md:-translate-x-1/2 md:-translate-y-1/2 pointer-events-none"
>
<a
class="text-foreground funnel-display text-xl font-bold tracking-tight whitespace-nowrap pointer-events-auto"
href="/">Serene Pub</a>
</div>
<!-- Desktop right nav -->
<nav
class="hidden flex-1 justify-end gap-2 lg:flex"
aria-label="Right navigation"
role="navigation"
>
{#each Object.entries(panelsCtx.rightNav) as [key, item]}
{@const isOpen = panelsCtx.rightPanel === key}
<button
class="btn-ghost"
title={item.title}
onclick={() => panelsCtx.openPanel({ key })}
aria-pressed={isOpen}
aria-label="Open {item.title} panel"
type="button"
>
<item.icon
class="{isOpen
? 'text-primary-800-200'
: ''} hover:text-primary-500 h-5 w-5 transition-colors"
aria-hidden="true"
/>
</button>
{/each}
</nav>
<!-- Desktop right nav -->
<div class="hidden flex-1 justify-end gap-2 md:flex">
{#each Object.entries(panelsCtx.rightNav) as [key, item]}
{@const isOpen = panelsCtx.rightPanel === key}
<button
class="btn-ghost"
title={item.title}
onclick={() => panelsCtx.openPanel({key})}
>
<item.icon class="{isOpen ? "text-primary-800-200" : ""} h-5 w-5 hover:text-primary-500 transition-colors" />
</button>
{/each}
</div>
<div class="flex items-center gap-2 md:hidden">
<button
class="btn preset-tonal"
aria-label="Open menu"
onclick={() => {
panelsCtx.isMobileMenuOpen = true
}}
>
<Icons.Menu class="text-foreground h-6 w-6" />
</button>
</div>
</div>
<div class="flex items-center gap-2 lg:hidden">
<button
class="btn preset-tonal"
aria-label="Open navigation menu"
onclick={() => {
panelsCtx.isMobileMenuOpen = true
}}
type="button"
aria-expanded={panelsCtx.isMobileMenuOpen}
>
<Icons.Menu class="text-foreground h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
</header>
<style lang="postcss">
@reference "tailwindcss";
header {
display: flex;
justify-content: space-between;
}
header {
display: flex;
justify-content: space-between;
}
</style>

View file

@ -6,6 +6,7 @@
import { onMount, setContext, onDestroy } from "svelte"
import SamplingSidebar from "./sidebars/SamplingSidebar.svelte"
import ConnectionsSidebar from "./sidebars/ConnectionsSidebar.svelte"
import OllamaSidebar from "./sidebars/OllamaSidebar.svelte"
import ContextSidebar from "./sidebars/ContextSidebar.svelte"
import LorebooksSidebar from "./sidebars/LorebooksSidebar.svelte"
import PersonasSidebar from "./sidebars/PersonasSidebar.svelte"
@ -15,9 +16,11 @@
import TagsSidebar from "./sidebars/TagsSidebar.svelte"
import * as skio from "sveltekit-io"
import { toaster } from "$lib/client/utils/toaster"
import { KeyboardNavigationManager } from "$lib/client/utils/keyboardNavigation"
import SettingsSidebar from "$lib/client/components/sidebars/SettingsSidebar.svelte"
import type { Snippet } from "svelte"
import { Theme } from "$lib/client/consts/Theme"
import OllamaIcon from "./icons/OllamaIcon.svelte"
interface Props {
children?: Snippet
@ -26,6 +29,12 @@
let { children }: Props = $props()
const socket = skio.get()
// Focus management refs
let mainContentRef: HTMLElement
let leftSidebarRef: HTMLElement
let rightSidebarRef: HTMLElement
let keyboardNavManager: KeyboardNavigationManager
let userCtx: { user: any } = $state({} as { user: any })
let panelsCtx: PanelsCtx = $state({
@ -38,29 +47,55 @@
onLeftPanelClose: undefined,
onRightPanelClose: undefined,
onMobilePanelClose: undefined,
leftNav: {
leftNav: {},
rightNav: {
tags: { icon: Icons.Tag, title: "Tags" },
personas: { icon: Icons.UserCog, title: "Personas" },
characters: { icon: Icons.Users, title: "Characters" },
lorebooks: { icon: Icons.BookMarked, title: "Lorebooks+" },
chats: { icon: Icons.MessageSquare, title: "Chats" }
},
digest: {}
})
let themeCtx: ThemeCtx = $state({
mode: (localStorage.getItem("mode") as "light" | "dark") || "dark",
theme: localStorage.getItem("theme") || Theme.HAMLINDIGO
})
let systemSettingsCtx: SystemSettingsCtx = $state({
settings: {
ollamaManagerEnabled: false,
ollamaManagerBaseUrl: "",
showAllCharacterFields: false,
enableEasyCharacterCreation: true,
enableEasyPersonaCreation: true,
showHomePageBanner: true
}
})
$effect(() => {
console.log(
"Layout systemSettingsCtx",
$state.snapshot(systemSettingsCtx)
)
})
// Update leftNav based on Ollama Manager setting
$effect(() => {
const baseLeftNav = {
sampling: {
icon: Icons.SlidersHorizontal,
title: "Sampling"
},
connections: { icon: Icons.Cable, title: "Connections" },
...(systemSettingsCtx.settings.ollamaManagerEnabled && {
ollama: { icon: OllamaIcon, title: "Ollama Manager" }
}),
contexts: { icon: Icons.BookOpenText, title: "Contexts" },
prompts: { icon: Icons.MessageCircle, title: "Prompts" },
settings: { icon: Icons.Settings, title: "Settings" }
},
rightNav: {
personas: { icon: Icons.UserCog, title: "Personas" },
characters: { icon: Icons.Users, title: "Characters" },
lorebooks: { icon: Icons.BookMarked, title: "Lorebooks+" },
tags: { icon: Icons.Tag, title: "Tags" },
chats: { icon: Icons.MessageSquare, title: "Chats" }
},
digest: {}
})
// TODO use setTheme socket call
let themeCtx: ThemeCtx = $state({
mode: (localStorage.getItem("mode") as "light" | "dark") || "dark",
theme: localStorage.getItem("theme") || Theme.HAMLINDIGO
}
panelsCtx.leftNav = baseLeftNav
})
function openPanel({
@ -137,18 +172,24 @@
}: {
panel: "left" | "right" | "mobile"
}) {
let res: boolean
let res: boolean = true // Default to allowing close
if (panel === "mobile") {
res = await panelsCtx.onMobilePanelClose!()
res = panelsCtx.onMobilePanelClose
? await panelsCtx.onMobilePanelClose()
: true
panelsCtx.mobilePanel = res ? null : panelsCtx.mobilePanel
} else if (panel === "left") {
res = await panelsCtx.onLeftPanelClose!()
res = panelsCtx.onLeftPanelClose
? await panelsCtx.onLeftPanelClose()
: true
panelsCtx.leftPanel = res ? null : panelsCtx.leftPanel
} else if (panel === "right") {
res = await panelsCtx.onRightPanelClose!()
res = panelsCtx.onRightPanelClose
? await panelsCtx.onRightPanelClose()
: true
panelsCtx.rightPanel = res ? null : panelsCtx.rightPanel
}
return res!
return res
}
function handleMobilePanelClick(key: string) {
@ -168,57 +209,118 @@
document.documentElement.setAttribute("data-theme", theme)
})
setContext("panelsCtx", panelsCtx as PanelsCtx)
setContext("userCtx", userCtx)
setContext("themeCtx", themeCtx)
onMount(() => {
socket.on("user", (message) => {
setContext("panelsCtx", panelsCtx as PanelsCtx)
setContext("userCtx", userCtx)
setContext("themeCtx", themeCtx)
setContext("systemSettingsCtx", systemSettingsCtx)
// Initialize keyboard navigation
keyboardNavManager = new KeyboardNavigationManager({
panelsCtx,
onFocusMain: () => {
if (mainContentRef) {
KeyboardNavigationManager.focusFirstInteractive(mainContentRef)
KeyboardNavigationManager.announceToScreenReader("Main content focused")
}
},
onFocusLeftSidebar: () => {
if (leftSidebarRef) {
KeyboardNavigationManager.focusFirstInteractive(leftSidebarRef)
const panelName = panelsCtx.leftNav[panelsCtx.leftPanel!]?.title || panelsCtx.leftPanel
KeyboardNavigationManager.announceToScreenReader(`${panelName} sidebar focused`)
}
},
onFocusRightSidebar: () => {
if (rightSidebarRef) {
KeyboardNavigationManager.focusFirstInteractive(rightSidebarRef)
const panelName = panelsCtx.rightNav[panelsCtx.rightPanel!]?.title || panelsCtx.rightPanel
KeyboardNavigationManager.announceToScreenReader(`${panelName} sidebar focused`)
}
}
})
keyboardNavManager.addGlobalListener()
socket.on("user", (message: Sockets.User.Response) => {
userCtx.user = message.user
})
socket.on(
"systemSettings",
(message: Sockets.SystemSettings.Response) => {
systemSettingsCtx.settings = message.systemSettings
}
)
socket.on("error", (message: Sockets.Error.Response) => {
toaster.error({ title: "Error", description: message.error })
toaster.error({
title: message.error,
description: message.description
})
})
socket.on("success", (message: Sockets.Success.Response) => {
toaster.success({
title: message.title,
description: message.description
})
})
socket.emit("user", {})
socket.emit("systemSettings", {})
})
onDestroy(() => {
keyboardNavManager?.removeGlobalListener()
socket.off("user")
socket.off("systemSettings")
socket.off("error")
socket.off("success")
})
</script>
{#if !!userCtx.user}
<div
class="bg-surface-100-900 relative h-full max-h-[100dvh] w-full justify-between"
role="application"
aria-label="Serene Pub Chat Application"
>
<div
class="relative flex h-svh min-w-full max-w-full flex-1 flex-col lg:flex-row lg:gap-2 overflow-hidden"
class="relative flex h-svh max-w-full min-w-full flex-1 flex-col overflow-hidden lg:flex-row lg:gap-2"
>
<!-- Left Sidebar -->
<aside class="desktop-sidebar">
<aside
class="desktop-sidebar"
role="complementary"
aria-label="Left navigation panel"
>
{#if panelsCtx.leftPanel}
{@const title =
panelsCtx.leftNav[panelsCtx.leftPanel]?.title ||
panelsCtx.leftPanel}
<div
bind:this={leftSidebarRef}
class="bg-surface-50-950 me-2 flex h-full w-full flex-col overflow-y-auto rounded-r-lg"
in:fly={{ x: -100, duration: 200 }}
out:fly={{ x: -100, duration: 200 }}
role="region"
aria-labelledby="left-panel-title"
aria-label="{title} sidebar - {Object.keys(panelsCtx.leftNav).indexOf(panelsCtx.leftPanel) + 1} of {Object.keys(panelsCtx.leftNav).length}"
tabindex="-1"
>
<div class="flex items-center justify-between p-4">
<span
<h2
id="left-panel-title"
class="text-foreground text-lg font-semibold capitalize"
>
{title}
</span>
</h2>
<button
class="btn-ghost"
onclick={() => closePanel({ panel: "left" })}
aria-label="Close {title} panel"
type="button"
>
<Icons.X class="text-foreground h-5 w-5" />
<Icons.X class="text-foreground h-5 w-5" aria-hidden="true" />
</button>
</div>
<div class="flex-1 overflow-y-auto">
@ -230,6 +332,10 @@
<ConnectionsSidebar
bind:onclose={panelsCtx.onLeftPanelClose}
/>
{:else if panelsCtx.leftPanel === "ollama"}
<OllamaSidebar
bind:onclose={panelsCtx.onLeftPanelClose}
/>
{:else if panelsCtx.leftPanel === "contexts"}
<ContextSidebar
bind:onclose={panelsCtx.onLeftPanelClose}
@ -248,37 +354,54 @@
{/if}
</aside>
<!-- Main Content -->
<main class="flex flex-col h-full overflow-hidden">
<main
bind:this={mainContentRef}
class="flex h-full flex-col overflow-hidden"
role="main"
tabindex="-1"
>
<Header />
<div class="flex-1 overflow-auto">
{@render children?.()}
</div>
</main>
<!-- Right Sidebar -->
<aside class="desktop-sidebar pt-1">
<aside
class="desktop-sidebar pt-1"
role="complementary"
aria-label="Right navigation panel"
>
{#if panelsCtx.rightPanel}
{@const title =
panelsCtx.rightNav[panelsCtx.rightPanel]?.title ||
panelsCtx.rightPanel}
<div
bind:this={rightSidebarRef}
class="bg-surface-50-950 flex h-full w-full flex-col overflow-y-auto rounded-l-lg"
in:fly={{ x: 100, duration: 200 }}
out:fly={{ x: 100, duration: 200 }}
role="region"
aria-labelledby="right-panel-title"
aria-label="{title} sidebar - {Object.keys(panelsCtx.rightNav).indexOf(panelsCtx.rightPanel) + 1} of {Object.keys(panelsCtx.rightNav).length}"
tabindex="-1"
>
<div class="flex items-center justify-between p-4">
<span
<h2
id="right-panel-title"
class="text-foreground text-lg font-semibold capitalize"
>
{title}
</span>
</h2>
<button
class="btn-ghost"
onclick={() => closePanel({ panel: "right" })}
aria-label="Close {title} panel"
type="button"
>
<Icons.X class="text-foreground h-5 w-5" />
<Icons.X class="text-foreground h-5 w-5" aria-hidden="true" />
</button>
</div>
<div class="flex-1 overflow-y-auto">
<nav class="flex-1 overflow-y-auto">
{#if panelsCtx.rightPanel === "personas"}
<PersonasSidebar
bind:onclose={panelsCtx.onRightPanelClose}
@ -300,7 +423,7 @@
bind:onclose={panelsCtx.onRightPanelClose}
/>
{/if}
</div>
</nav>
</div>
{/if}
</aside>
@ -312,6 +435,9 @@
]?.title || panelsCtx.mobilePanel}
<div
class="bg-surface-100-900 fixed inset-0 z-[51] flex flex-col overflow-y-auto lg:hidden"
role="dialog"
aria-labelledby="mobile-panel-title"
aria-modal="true"
>
<div
class="border-border flex items-center justify-between border-b p-4"
@ -337,6 +463,10 @@
<ConnectionsSidebar
bind:onclose={panelsCtx.onMobilePanelClose}
/>
{:else if panelsCtx.mobilePanel === "ollama"}
<OllamaSidebar
bind:onclose={panelsCtx.onMobilePanelClose}
/>
{:else if panelsCtx.mobilePanel === "contexts"}
<ContextSidebar
bind:onclose={panelsCtx.onMobilePanelClose}
@ -428,6 +558,6 @@
/* w-[25%] max-w-[25%] */
.desktop-sidebar {
@apply hidden min-h-full max-h-full basis-1/4 overflow-x-hidden lg:block py-1;
@apply hidden max-h-full min-h-full basis-1/4 overflow-x-hidden py-1 lg:block;
}
</style>

View file

@ -9,6 +9,9 @@
onclick: (e: MouseEvent) => void
contentTitle: string
classes?: string
itemType?: string // e.g., "Character", "Chat", "Persona"
totalItems?: number // Total items in the list for context
currentIndex?: number // Current position in list
}
let {
@ -18,29 +21,55 @@
controls,
onclick,
contentTitle,
classes = ""
classes = "",
itemType = "Item",
totalItems,
currentIndex
}: Props = $props()
// Create comprehensive aria-label
const ariaLabel = $derived((() => {
let label = `${itemType}: ${contentTitle}`
if (currentIndex !== undefined && totalItems !== undefined) {
label += ` - ${currentIndex + 1} of ${totalItems}`
} else if (id !== undefined) {
label += ` - ID ${id}`
}
return label
})())
</script>
<div
class="card preset-tonal hover:preset-filled-surface-300-700 relative flex w-full gap-2 overflow-hidden rounded-lg py-2 pr-3 pl-2 {classes}"
role="listitem"
>
<div class="relative flex flex-1 gap-2 min-w-0">
<div class="relative flex min-w-0 flex-1 gap-2">
{#if id !== undefined}
<button {onclick} class="flex gap-2 min-w-0" title={contentTitle}>
<button
{onclick}
class="flex min-w-0 gap-2"
title={contentTitle}
aria-label={ariaLabel}
type="button"
>
<span
class="text-muted-foreground my-auto h-fit w-8 flex-shrink-0 text-xs text-center"
class="text-muted-foreground my-auto h-fit w-8 flex-shrink-0 text-center text-xs"
aria-hidden="true"
>
{id}
</span>
</button>
{/if}
<div class="flex w-full flex-col min-w-0">
<div class="flex w-full min-w-0 flex-col">
<div class="flex min-w-0">
<button
{onclick}
class="flex flex-1 gap-2 min-w-0"
class="flex min-w-0 flex-1 gap-2"
title={contentTitle}
aria-label={ariaLabel}
type="button"
>
{@render content()}
</button>
@ -48,5 +77,11 @@
{@render extraContent?.()}
</div>
</div>
{@render controls?.()}
<div
class="controls"
role="group"
aria-label="Actions for {contentTitle}"
>
{@render controls?.()}
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -10,21 +10,40 @@
import { Switch } from "@skeletonlabs/skeleton-svelte"
import { toaster } from "$lib/client/utils/toaster"
import { GroupReplyStrategies } from "$lib/shared/constants/GroupReplyStrategies"
import { ChatCharacterVisibility } from "$lib/shared/constants/ChatCharacterVisibility"
import { z } from "zod"
// Zod validation schema
const chatSchema = z.object({
name: z.string().min(1, "Chat name is required").trim(),
scenario: z.string().optional(),
groupReplyStrategy: z.string().optional()
})
type ValidationErrors = Record<string, string>
interface Props {
editChatId?: number | null // If provided, edit mode; else create mode
showEditChatForm: boolean // Controls visibility of the form
hasChanges?: boolean // Track if the form has unsaved changes
}
let {
editChatId = $bindable(null),
showEditChatForm = $bindable()
showEditChatForm = $bindable(),
hasChanges = $bindable(false)
}: Props = $props()
const socket = skio.get()
// STATE VARIABLES
// Tag-related state
let tagsList: SelectTag[] = $state([])
let tagSearchInput = $state("")
let showTagSuggestions = $state(false)
let selectedTags: string[] = $state([])
let chat: Sockets.Chat.Response["chat"] | undefined = $state()
let isCreating = $state(!chat)
let characters: Sockets.CharacterList.Response["characterList"] = $state([])
@ -40,6 +59,7 @@
scenario: string
groupReplyStrategy: string
lorebookId?: number | null
tags: string[]
}
characterIds: number[]
personaIds: number[]
@ -55,6 +75,7 @@
scenario: string
groupReplyStrategy: string
lorebookId?: number | null
tags: string[]
}
characterIds: number[]
personaIds: number[]
@ -84,6 +105,11 @@
data?.personaIds.length > 0)
)
// Sync hasChanges with isDirty
$effect(() => {
hasChanges = isDirty
})
// SELECTED CHARACTERS AND PERSONAS
let selectedCharacters: SelectCharacter[] = $state([])
let selectedPersonas: SelectPersona[] = $state([])
@ -91,6 +117,57 @@
let removeType: "character" | "persona" = $state("character")
let removeName = $state("")
let removeId: number | null = $state(null)
let validationErrors: ValidationErrors = $state({})
// Filtered tags for suggestions
let filteredTags = $derived.by(() => {
if (!tagSearchInput)
return tagsList.filter(
(tag) =>
!selectedTags.some(
(selectedTag) =>
selectedTag.toLowerCase() === tag.name.toLowerCase()
)
)
return tagsList.filter(
(tag) =>
tag.name.toLowerCase().includes(tagSearchInput.toLowerCase()) &&
!selectedTags.some(
(selectedTag) =>
selectedTag.toLowerCase() === tag.name.toLowerCase()
)
)
})
// Tag helper functions
function addTag(tagName: string) {
const trimmedName = tagName.trim()
if (!trimmedName) return
// Check for case-insensitive duplicates
const isDuplicate = selectedTags.some(
(existingTag) =>
existingTag.toLowerCase() === trimmedName.toLowerCase()
)
if (isDuplicate) return
selectedTags = [...selectedTags, trimmedName]
tagSearchInput = ""
showTagSuggestions = false
}
function removeTag(tagName: string) {
selectedTags = selectedTags.filter((tag) => tag !== tagName)
}
function handleTagInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && tagSearchInput.trim()) {
e.preventDefault()
addTag(tagSearchInput)
} else if (e.key === "Escape") {
showTagSuggestions = false
}
}
$effect(() => {
const _name = name.trim()
@ -99,13 +176,15 @@
const _selectedCharacters = selectedCharacters
const _selectedPersonas = selectedPersonas
const _lorebookId = lorebookId || null
const _tags = selectedTags
data = {
chat: {
id: chat?.id,
name: _name,
scenario: _scenario,
groupReplyStrategy: _groupReplyStrategy || "ordered",
lorebookId: _lorebookId
lorebookId: _lorebookId,
tags: _tags
},
characterIds: _selectedCharacters.map((cc) => cc.id),
personaIds: _selectedPersonas.map((cp) => cp.id),
@ -115,7 +194,7 @@
}
if (!originalData) {
originalData = { ...data }
originalData = JSON.parse(JSON.stringify(data))
}
})
@ -147,6 +226,7 @@
}
function handleSave() {
if (!validateForm()) return
if (
!data?.chat.name.trim() ||
selectedCharacters.length === 0 ||
@ -168,7 +248,6 @@
socket.emit("createChat", createChat)
}
isCreating = false
showEditChatForm = false
}
function confirmRemoveCharacter(id: number, name: string) {
@ -199,6 +278,28 @@
removeName = ""
}
function validateForm(): boolean {
const result = chatSchema.safeParse({
name: name,
scenario: scenario,
groupReplyStrategy: groupReplyStrategy
})
if (result.success) {
validationErrors = {}
return true
} else {
const errors: ValidationErrors = {}
result.error.errors.forEach((error) => {
if (error.path.length > 0) {
errors[error.path[0] as string] = error.message
}
})
validationErrors = errors
return false
}
}
function handleCloseForm() {
// TODO handle unsaved changes if any
showEditChatForm = false
@ -216,6 +317,9 @@
selectedPersonas =
chat.chatPersonas?.map((cp) => cp.persona) || []
lorebookId = chat.lorebookId || null
selectedTags = chat.tags || []
// Reset originalData to null so it gets re-initialized with the loaded data
originalData = undefined
}
})
socket.on("characterList", (msg: Sockets.CharacterList.Response) => {
@ -227,6 +331,9 @@
socket.on("lorebookList", (msg: Sockets.LorebookList.Response) => {
lorebookList = msg.lorebookList || []
})
socket.on("tagsList", (msg: any) => {
tagsList = msg.tagsList || []
})
socket.on(
"toggleChatCharacterActive",
(msg: Sockets.ToggleChatCharacterActive.Response) => {
@ -237,9 +344,37 @@
}
}
)
socket.on(
"updateChatCharacterVisibility",
(msg: Sockets.UpdateChatCharacterVisibility.Response) => {
if (chat && chat.id === msg.chatId) {
const visibilityLabel = ChatCharacterVisibility.options.find(
opt => opt.value === msg.visibility
)?.label || msg.visibility
toaster.success({
title: `Character visibility set to ${visibilityLabel}`
})
}
}
)
socket.on("createChat", (res: any) => {
toaster.success({
title: "Chat Created",
description: `Chat "${res.chat.name || "Unnamed Chat"}" created successfully.`
})
showEditChatForm = false
})
socket.on("updateChat", (res: any) => {
toaster.success({
title: "Chat Updated",
description: `Chat "${res.chat.name || "Unnamed Chat"}" updated successfully.`
})
showEditChatForm = false
})
socket.emit("characterList", {})
socket.emit("personaList", {})
socket.emit("lorebookList", {})
socket.emit("tagsList", {})
})
onDestroy(() => {
@ -247,7 +382,11 @@
socket.off("characterList")
socket.off("personaList")
socket.off("lorebookList")
socket.off("tagsList")
socket.off("toggleChatCharacterActive")
socket.off("updateChatCharacterVisibility")
socket.off("createChat")
socket.off("updateChat")
})
function toggleCharacterActive(
@ -260,6 +399,57 @@
}
socket.emit("toggleChatCharacterActive", req)
}
function updateCharacterVisibility(
c: SelectCharacter,
visibility: string
): void {
const req: Sockets.UpdateChatCharacterVisibility.Call = {
chatId: chat!.id,
characterId: c.id,
visibility
}
socket.emit("updateChatCharacterVisibility", req)
}
function getVisibilityIcon(visibility: string) {
switch (visibility) {
case ChatCharacterVisibility.VISIBLE:
return Icons.Eye
case ChatCharacterVisibility.MINIMAL:
return Icons.EyeClosed
case ChatCharacterVisibility.HIDDEN:
return Icons.EyeOff
default:
return Icons.Eye
}
}
function getVisibilityColor(visibility: string) {
switch (visibility) {
case ChatCharacterVisibility.VISIBLE:
return "text-success-500"
case ChatCharacterVisibility.MINIMAL:
return "text-warning-500"
case ChatCharacterVisibility.HIDDEN:
return "text-error-500"
default:
return "text-success-500"
}
}
function getNextVisibility(current: string): string {
switch (current) {
case ChatCharacterVisibility.VISIBLE:
return ChatCharacterVisibility.MINIMAL
case ChatCharacterVisibility.MINIMAL:
return ChatCharacterVisibility.HIDDEN
case ChatCharacterVisibility.HIDDEN:
return ChatCharacterVisibility.VISIBLE
default:
return ChatCharacterVisibility.VISIBLE
}
}
</script>
{#if data}
@ -283,12 +473,25 @@
<label class="font-semibold" for="chatName">Chat Name*</label>
<input
id="chatName"
class="input input-lg w-full"
class="input input-lg w-full {validationErrors.name
? 'border-red-500'
: ''}"
type="text"
placeholder="Enter chat name"
bind:value={name}
required
oninput={() => {
if (validationErrors.name) {
const { name, ...rest } = validationErrors
validationErrors = rest
}
}}
/>
{#if validationErrors.name}
<p class="mt-1 text-sm text-red-500" role="alert">
{validationErrors.name}
</p>
{/if}
</div>
<div>
<span class="mb-2 font-semibold">Characters*</span>
@ -305,9 +508,16 @@
onfinalize={(e) => (selectedCharacters = e.detail.items)}
>
{#each selectedCharacters as c (c.id)}
{@const isActive = chat ? !!chat?.chatCharacters?.find(
(cc) => cc.characterId === c.id
)?.isActive : true}
{@const isActive = chat
? !!chat?.chatCharacters?.find(
(cc) => cc.characterId === c.id
)?.isActive
: true}
{@const visibility = chat
? chat?.chatCharacters?.find(
(cc) => cc.characterId === c.id
)?.visibility || ChatCharacterVisibility.VISIBLE
: ChatCharacterVisibility.VISIBLE}
<div class="flex gap-2">
<div
class="group preset-outlined-surface-400-600 hover:preset-filled-surface-500 relative flex w-full gap-3 overflow-hidden rounded p-3"
@ -341,39 +551,54 @@
</div>
</div>
<div
class="flex flex-col justify-between py-1 text-center"
class="flex flex-col justify-between py-1 text-center gap-2"
>
<button
class="preset-tonal-error btn btn-sm opacity-75"
onclick={() =>
confirmRemoveCharacter(
c.id,
c.nickname || c.name
)}
title="Remove"
>
<Icons.X size={16} />
</button>
<span title="Toggle Character Active">
<Switch
name="Toggle Character Active"
controlWidth="w-9"
controlActive="preset-filled-success-500"
controlDisabled="preset-filled-surface-500"
compact
checked={isActive}
disabled={!chat}
onCheckedChange={(e) =>
toggleCharacterActive(e, c)}
<!-- Show remove button only when creating (no chat) -->
{#if !chat}
<button
class="preset-tonal-error btn btn-sm opacity-75"
onclick={() =>
confirmRemoveCharacter(
c.id,
c.nickname || c.name
)}
title="Remove"
>
{#snippet inactiveChild()}<Icons.Meh
size="20"
/>{/snippet}
{#snippet activeChild()}<Icons.Smile
size="20"
/>{/snippet}
</Switch>
</span>
<Icons.X size={16} />
</button>
{/if}
<!-- Show character controls only when editing (chat exists) -->
{#if chat}
<div class="flex flex-col gap-1">
<span title="Toggle Character Active">
<Switch
name="toggle-character-active-{c.id}"
controlWidth="w-9"
controlActive="preset-filled-success-500"
controlDisabled="preset-filled-surface-500"
compact
checked={isActive}
onCheckedChange={(e) =>
toggleCharacterActive(e, c)}
aria-label="Toggle character {c.name} active status"
>
{#snippet inactiveChild()}<Icons.Meh
size="20"
/>{/snippet}
{#snippet activeChild()}<Icons.Smile
size="20"
/>{/snippet}
</Switch>
</span>
<button
class="btn btn-sm {getVisibilityColor(visibility)} hover:scale-110 transition-transform"
onclick={() => updateCharacterVisibility(c, getNextVisibility(visibility))}
title="Context Optimization: {ChatCharacterVisibility.options.find(opt => opt.value === visibility)?.label || 'Full Info'}"
>
<svelte:component this={getVisibilityIcon(visibility)} size={20} />
</button>
</div>
{/if}
</div>
</div>
{/each}
@ -413,27 +638,33 @@
{p.description || ""}
</div>
</div>
<button
class="text-text-error-500 absolute -top-2 -right-2 z-10 mt-2 mr-2 opacity-0 group-hover:opacity-100"
onclick={() =>
confirmRemovePersona(p.id, p.name)}
title="Remove"
>
<Icons.X size={26} class="text-error-500" />
</button>
<!-- Show remove button only when creating (no chat) -->
{#if !chat}
<button
class="text-text-error-500 absolute -top-2 -right-2 z-10 mt-2 mr-2 opacity-0 group-hover:opacity-100"
onclick={() =>
confirmRemovePersona(p.id, p.name)}
title="Remove"
>
<Icons.X size={26} class="text-error-500" />
</button>
{/if}
</div>
<div
class="flex flex-col justify-between py-1 text-center"
>
<button
class="preset-tonal-error btn btn-sm opacity-75"
onclick={() =>
confirmRemovePersona(p.id, p.name)}
title="Remove"
<!-- Show remove button only when creating (no chat) -->
{#if !chat}
<div
class="flex flex-col justify-between py-1 text-center"
>
<Icons.X size={16} />
</button>
</div>
<button
class="preset-tonal-error btn btn-sm opacity-75"
onclick={() =>
confirmRemovePersona(p.id, p.name)}
title="Remove"
>
<Icons.X size={16} />
</button>
</div>
{/if}
</div>
{/each}
</div>
@ -506,6 +737,73 @@
{/each}
</select>
</div>
<!-- Tags Section -->
<div class="pb-10">
<label class="font-semibold" for="tagInput">Tags</label>
<div class="relative">
<input
id="tagInput"
type="text"
bind:value={tagSearchInput}
class="input input-lg w-full"
placeholder="Add a tag..."
onfocus={() => (showTagSuggestions = true)}
onblur={() =>
setTimeout(() => (showTagSuggestions = false), 200)}
onkeydown={handleTagInputKeydown}
/>
<!-- Tag suggestions dropdown -->
{#if showTagSuggestions && filteredTags.length > 0}
<div
class="bg-surface-100-900 absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded-lg border shadow-lg"
>
{#each filteredTags as tag}
<button
type="button"
class="hover:bg-surface-200-800 w-full px-3 py-2 text-left transition-colors"
onclick={() => addTag(tag.name)}
>
<span
class="chip mr-2 {tag.colorPreset ||
'preset-filled-primary-500'}"
>
{tag.name}
</span>
{#if tag.description}
<span class="text-muted-foreground text-sm">
- {tag.description}
</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Selected tags display -->
{#if selectedTags.length > 0}
<div class="mt-2 flex flex-wrap gap-2">
{#each selectedTags as tagName}
{@const tag = tagsList.find((t) => t.name === tagName)}
<button
type="button"
class="chip {tag?.colorPreset ||
'preset-filled-primary-500'} group relative"
onclick={() => removeTag(tagName)}
title="Click to remove tag"
>
{tagName}
<Icons.X
size={14}
class="ml-1 opacity-60 group-hover:opacity-100"
/>
</button>
{/each}
</div>
{/if}
</div>
</div>
<CharacterSelectModal
open={showCharacterModal}

View file

@ -1,115 +1,168 @@
<script lang="ts">
import { Tabs } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import { onMount, type Snippet } from "svelte";
import { renderMarkdownWithQuotedText } from "$lib/client/utils/markdownToHTML"
import { Tabs } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import { onMount, type Snippet } from "svelte"
import { renderMarkdownWithQuotedText } from "$lib/client/utils/markdownToHTML"
interface Props {
markdown: string
classes?: string
compiledPrompt?: CompiledPrompt
leftControls?: Snippet
rightControls?: Snippet
extraTabs?: {
value: string
title: string
control: Snippet
content: Snippet
}[]
onSend: () => void
}
let {
markdown = $bindable(),
compiledPrompt = $bindable(),
classes,
leftControls,
rightControls,
extraTabs = $bindable(),
onSend
}: Props = $props()
interface Props {
markdown: string
classes?: string
compiledPrompt?: CompiledPrompt
leftControls?: Snippet
rightControls?: Snippet
extraTabs?: {
value: string
title: string
control: Snippet
content: Snippet
}[]
onSend: () => void
}
let {
markdown = $bindable(),
compiledPrompt = $bindable(),
classes,
leftControls,
rightControls,
extraTabs = $bindable(),
onSend
}: Props = $props()
let tabGroup: "compose" | "preview" = $state("compose")
let contextExceeded = $derived( !!compiledPrompt ? compiledPrompt!.meta.tokenCounts.total > compiledPrompt!.meta.tokenCounts.limit : false )
let submitOnEnter = $state(true)
let tabGroup: "compose" | "preview" = $state("compose")
let contextExceeded = $derived(
!!compiledPrompt
? compiledPrompt!.meta.tokenCounts.total >
compiledPrompt!.meta.tokenCounts.limit
: false
)
let submitOnEnter = $state(true)
function handleSend(e: KeyboardEvent | MouseEvent | undefined = undefined) {
if (e) e.preventDefault()
onSend()
}
function handleSend(e: KeyboardEvent | MouseEvent | undefined = undefined) {
if (e) e.preventDefault()
onSend()
}
onMount(() => {
const mq = window.matchMedia("(min-width: 1024px)")
onMount(() => {
const mq = window.matchMedia("(min-width: 1024px)")
const update = () => (submitOnEnter = mq.matches)
update()
mq.addEventListener("change", update)
return () => mq.removeEventListener("change", update)
})
})
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey && submitOnEnter) {
e.preventDefault()
handleSend(e)
}}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey && submitOnEnter) {
e.preventDefault()
handleSend(e)
}
}
$effect(() => {
console.log("compiledPrompt", $state.snapshot(compiledPrompt))
})
$effect(() => {
console.log("compiledPrompt", $state.snapshot(compiledPrompt))
})
</script>
<Tabs value={tabGroup} {classes} onValueChange={(e) => (tabGroup = e.value as "compose" | "preview")}>
{#snippet list()}
<Tabs.Control value="compose" classes="min-h-[2.75em]"
><span title="Compose"><Icons.Pen size="0.75em" /></span></Tabs.Control
>
<Tabs.Control value="preview" classes="min-h-[2.75em]"
><span title="Preview"><Icons.Eye size="0.75em" /></span></Tabs.Control
>
{#if extraTabs}
{#each extraTabs as tab}
<Tabs.Control value={tab.value} classes="min-h-[2.75em]" >
<span title={tab.title}>{@render tab.control?.()}</span>
</Tabs.Control>
{/each}
{/if}
{#if compiledPrompt}
<Tabs.Control value="tokenCount" classes="w-full text-right min-h-[2.75]" disabled >
<span title="Token Count" class="text-xs" class:text-error-500={contextExceeded}>
{compiledPrompt.meta.tokenCounts.total} / {compiledPrompt.meta.tokenCounts.limit}
</span>
</Tabs.Control>
{/if}
{/snippet}
{#snippet content()}
<div class="flex gap-4">
{@render leftControls?.()}
<div class="w-full">
<Tabs.Panel value="compose">
<textarea
class="input field-sizing-content lg:min-h-[3.75em] flex-1 rounded-xl"
placeholder="Type a message..."
bind:value={markdown}
autocomplete="off"
spellcheck="true"
onkeydown={handleKeyDown}
>
</textarea>
</Tabs.Panel>
<Tabs.Panel value="preview">
<div class="card bg-surface-100-900 min-h-[4em] w-full rounded-lg p-2">
<div class="rendered-chat-message-content">
{@html renderMarkdownWithQuotedText(markdown)}
</div>
</div>
</Tabs.Panel>
{#if extraTabs}
{#each extraTabs as tab}
<Tabs.Panel value={tab.value}>
{@render tab.content?.()}
</Tabs.Panel>
{/each}
{/if}
</div>
{@render rightControls?.()}
</div>
{/snippet}
<Tabs
value={tabGroup}
{classes}
onValueChange={(e) => (tabGroup = e.value as "compose" | "preview")}
role="region"
aria-label="Message composer"
>
{#snippet list()}
<Tabs.Control value="compose" classes="min-h-[2.75em]">
<span title="Compose" aria-label="Compose tab">
<Icons.Pen size="0.75em" aria-hidden="true" />
</span>
</Tabs.Control>
<Tabs.Control value="preview" classes="min-h-[2.75em]">
<span title="Preview" aria-label="Preview tab">
<Icons.Eye size="0.75em" aria-hidden="true" />
</span>
</Tabs.Control>
{#if extraTabs}
{#each extraTabs as tab}
<Tabs.Control value={tab.value} classes="min-h-[2.75em]">
<span title={tab.title} aria-label="{tab.title} tab">
{@render tab.control?.()}
</span>
</Tabs.Control>
{/each}
{/if}
{#if compiledPrompt}
<Tabs.Control
value="tokenCount"
classes="w-full text-right min-h-[2.75]"
disabled
>
<span
title="Token Count"
class="text-xs"
class:text-error-500={contextExceeded}
aria-label="Token count: {compiledPrompt.meta.tokenCounts.total} of {compiledPrompt.meta.tokenCounts.limit}"
aria-live="polite"
>
{compiledPrompt.meta.tokenCounts.total} / {compiledPrompt
.meta.tokenCounts.limit}
</span>
</Tabs.Control>
{/if}
{/snippet}
{#snippet content()}
<div class="flex gap-4">
<div role="group" aria-label="Message controls">
{@render leftControls?.()}
</div>
<div class="w-full">
<Tabs.Panel value="compose">
<label class="sr-only" for="message-input">
Type your message here
</label>
<textarea
id="message-input"
class="input field-sizing-content flex-1 rounded-xl lg:min-h-[3.75em]"
placeholder="Type a message..."
bind:value={markdown}
autocomplete="off"
spellcheck="true"
onkeydown={handleKeyDown}
aria-describedby={contextExceeded ? "token-warning" : undefined}
aria-invalid={contextExceeded}
></textarea>
{#if contextExceeded}
<div
id="token-warning"
class="text-error-500 text-xs mt-1"
role="alert"
>
Token limit exceeded. Message may be truncated.
</div>
{/if}
</Tabs.Panel>
<Tabs.Panel value="preview">
<div
class="card bg-surface-100-900 min-h-[4em] w-full rounded-lg p-2"
role="region"
aria-label="Message preview"
>
<div class="rendered-chat-message-content">
{@html renderMarkdownWithQuotedText(markdown)}
</div>
</div>
</Tabs.Panel>
{#if extraTabs}
{#each extraTabs as tab}
<Tabs.Panel value={tab.value}>
<div role="region" aria-label="{tab.title} content">
{@render tab.content?.()}
</div>
</Tabs.Panel>
{/each}
{/if}
</div>
<div role="group" aria-label="Send controls">
{@render rightControls?.()}
</div>
</div>
{/snippet}
</Tabs>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,104 @@
<script lang="ts">
import { Avatar } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import SidebarListItem from "../SidebarListItem.svelte"
interface Props {
character: Sockets.CharacterList.Response["characterList"][0]
onclick?: (character: Sockets.CharacterList.Response["characterList"][0]) => void
onEdit?: (id: number) => void
onDelete?: (id: number) => void
showControls?: boolean
contentTitle?: string
classes?: string
}
let {
character,
onclick,
onEdit,
onDelete,
showControls = true,
contentTitle = "Go to character",
classes = ""
}: Props = $props()
function handleClick() {
onclick?.(character)
}
function handleEditClick(e: MouseEvent) {
e.stopPropagation()
onEdit?.(character.id!)
}
function handleDeleteClick(e: MouseEvent) {
e.stopPropagation()
onDelete?.(character.id!)
}
</script>
<SidebarListItem
id={character.id}
onclick={handleClick}
{contentTitle}
itemType="Character"
classes={character.isFavorite ? "border border-primary-500 " + classes : classes}
>
{#snippet content()}
<Avatar
src={character.avatar || ""}
size="w-[4em] h-[4em] min-w-[4em] min-h-[4em]"
imageClasses="object-cover"
name={character.nickname || character.name!}
>
<Icons.User size={36} aria-hidden="true" />
</Avatar>
<div class="relative flex min-w-0 flex-1 gap-2">
<div class="relative min-w-0 flex-1">
<div
class="truncate text-left font-semibold"
id="character-name-{character.id}"
>
{character.nickname || character.name}
</div>
{#if character.description}
<div
class="text-muted-foreground line-clamp-2 text-left text-xs"
id="character-desc-{character.id}"
>
{character.description}
</div>
{/if}
</div>
</div>
{/snippet}
{#snippet controls()}
{#if showControls && (onEdit || onDelete)}
<div class="flex flex-col gap-4" role="group" aria-labelledby="character-name-{character.id}">
{#if onEdit}
<button
class="btn btn-sm text-primary-500 p-2"
onclick={handleEditClick}
title="Edit Character"
aria-label="Edit {character.nickname || character.name}"
type="button"
>
<Icons.Edit size={16} aria-hidden="true" />
</button>
{/if}
{#if onDelete}
<button
class="btn btn-sm text-error-500 p-2"
onclick={handleDeleteClick}
title="Delete Character"
aria-label="Delete {character.nickname || character.name}"
type="button"
>
<Icons.Trash2 size={16} aria-hidden="true" />
</button>
{/if}
</div>
{/if}
{/snippet}
</SidebarListItem>

View file

@ -0,0 +1,132 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import Avatar from "../Avatar.svelte"
import SidebarListItem from "../SidebarListItem.svelte"
interface Props {
chat: Sockets.ChatsList.Response["chatsList"][0]
onclick?: (chat: Sockets.ChatsList.Response["chatsList"][0]) => void
onEdit?: (id: number) => void
onDelete?: (id: number) => void
showControls?: boolean
contentTitle?: string
classes?: string
}
let {
chat,
onclick,
onEdit,
onDelete,
showControls = true,
contentTitle = "Go to chat",
classes = ""
}: Props = $props()
const avatars = $derived([
...(chat.chatCharacters || []).map((cc) => ({
type: "character",
data: cc.character
})),
...(chat.chatPersonas || []).map((cp) => ({
type: "persona",
data: cp.persona
}))
])
function handleClick() {
onclick?.(chat)
}
function handleEditClick(e: MouseEvent) {
e.stopPropagation()
onEdit?.(chat.id!)
}
function handleDeleteClick(e: MouseEvent) {
e.stopPropagation()
onDelete?.(chat.id!)
}
</script>
<SidebarListItem
itemType="Chat"
onclick={handleClick}
{contentTitle}
{classes}
>
{#snippet content()}
<div class="relative w-fit">
<div class="relative mr-2 flex flex-shrink-0 flex-grow-0 items-center">
{#if avatars.length <= 2}
{#each avatars as avatar, i}
<div
class="inline-block"
style="margin-left: {i === 0 ? '0' : '-0.7em'}; z-index: {10 - i};"
>
<Avatar char={avatar.data} />
</div>
{/each}
{:else}
{#each avatars.slice(0, 3) as avatar, i}
<div
class="ml-[-2.25em] inline-block first:ml-0"
style="z-index: {10 - i};"
>
<Avatar char={avatar.data} />
</div>
{/each}
{#if avatars.length > 3}
<div class="preset-tonal-secondary relative z-1 mb-auto aspect-square rounded-full px-1 pt-[0.15em] text-xs select-none">
+{avatars.length - 3}
</div>
{/if}
{/if}
</div>
</div>
<div class="flex min-w-0 flex-col">
<div class="truncate text-left font-semibold">
{chat.name || "Untitled Chat"}
</div>
<div class="text-muted-foreground line-clamp-2 text-left text-xs">
{#if chat.chatCharacters?.length}
{chat.chatCharacters
.map((cc) => cc.character?.nickname || cc.character?.name)
.filter(Boolean)
.join(", ")}
{/if}
{chat.chatPersonas?.length ? "," : ""}
{#if chat.chatPersonas?.length}
{chat.chatPersonas
.map((cp) => cp.persona?.name)
.filter(Boolean)
.join(", ")}
{/if}
</div>
</div>
{/snippet}
{#snippet controls()}
{#if showControls && (onEdit || onDelete)}
<div class="ml-auto flex flex-col gap-4">
{#if onEdit}
<button
class="btn btn-sm text-primary-500 p-4"
onclick={handleEditClick}
title="Edit Chat"
>
<Icons.Edit size={16} />
</button>
{/if}
{#if onDelete}
<button
class="btn btn-sm text-error-500 p-4"
onclick={handleDeleteClick}
title="Delete Chat"
>
<Icons.Trash2 size={16} />
</button>
{/if}
</div>
{/if}
{/snippet}
</SidebarListItem>

View file

@ -0,0 +1,137 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import SidebarListItem from "../SidebarListItem.svelte"
interface Props {
lorebook: any
onclick?: (lorebook: any) => void
onEdit?: (id: number) => void
onDelete?: (id: number) => void
showControls?: boolean
contentTitle?: string
classes?: string
bindingsCount?: number
worldEntriesCount?: number
characterEntriesCount?: number
historyEntriesCount?: number
}
let {
lorebook,
onclick,
onEdit,
onDelete,
showControls = true,
contentTitle = "Go to lorebook",
classes = "",
bindingsCount = 0,
worldEntriesCount = 0,
characterEntriesCount = 0,
historyEntriesCount = 0
}: Props = $props()
function handleClick() {
onclick?.(lorebook)
}
function handleEditClick(e: MouseEvent) {
e.stopPropagation()
onEdit?.(lorebook.id!)
}
function handleDeleteClick(e: MouseEvent) {
e.stopPropagation()
onDelete?.(lorebook.id!)
}
</script>
<SidebarListItem
itemType="Lorebook"
id={lorebook.id}
onclick={handleClick}
{contentTitle}
{classes}
>
{#snippet content()}
<div class="flex w-full items-center gap-2">
<div class="relative flex min-w-0 flex-1 gap-2">
<div class="relative min-w-0 flex-1">
<div class="truncate text-left font-semibold">
{lorebook.name}
</div>
{#if lorebook.description}
<div
class="text-muted-foreground line-clamp-2 text-left text-xs"
>
{lorebook.description}
</div>
{/if}
</div>
</div>
</div>
{/snippet}
{#snippet extraContent()}
<div class="flex gap-2 text-xs">
{#if bindingsCount > 0}
<div
class="flex items-center gap-1"
title="Bindings"
>
<Icons.Link size={12} />
{bindingsCount}
</div>
{/if}
{#if worldEntriesCount > 0}
<div
class="flex items-center gap-1"
title="World entries"
>
<Icons.Globe size={12} />
{worldEntriesCount}
</div>
{/if}
{#if characterEntriesCount > 0}
<div
class="flex items-center gap-1"
title="Character entries"
>
<Icons.User size={12} />
{characterEntriesCount}
</div>
{/if}
{#if historyEntriesCount > 0}
<div
class="flex items-center gap-1"
title="History entries"
>
<Icons.Clock size={12} />
{historyEntriesCount}
</div>
{/if}
</div>
{/snippet}
{#snippet controls()}
{#if showControls && (onEdit || onDelete)}
<div class="flex flex-col gap-4">
{#if onEdit}
<button
class="btn btn-sm text-primary-500 p-2"
onclick={handleEditClick}
title="Edit Lorebook"
>
<Icons.Edit size={16} />
</button>
{/if}
{#if onDelete}
<button
class="btn btn-sm text-error-500 p-2"
onclick={handleDeleteClick}
title="Delete Lorebook"
>
<Icons.Trash2 size={16} />
</button>
{/if}
</div>
{/if}
{/snippet}
</SidebarListItem>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import Avatar from "../Avatar.svelte"
import SidebarListItem from "../SidebarListItem.svelte"
interface Props {
persona: Sockets.PersonaList.Response["personaList"][0]
onclick?: (persona: Sockets.PersonaList.Response["personaList"][0]) => void
onEdit?: (id: number) => void
onDelete?: (id: number) => void
showControls?: boolean
contentTitle?: string
classes?: string
}
let {
persona,
onclick,
onEdit,
onDelete,
showControls = true,
contentTitle = "Go to persona",
classes = ""
}: Props = $props()
function handleClick() {
onclick?.(persona)
}
function handleEditClick(e: MouseEvent) {
e.stopPropagation()
onEdit?.(persona.id!)
}
function handleDeleteClick(e: MouseEvent) {
e.stopPropagation()
onDelete?.(persona.id!)
}
</script>
<SidebarListItem
id={persona.id}
onclick={handleClick}
{contentTitle}
itemType="Persona"
{classes}
>
{#snippet content()}
<Avatar
src={persona.avatar || ""}
size="w-[4em] h-[4em] min-w-[4em] min-h-[4em]"
imageClasses="object-cover"
name={persona.name!}
>
<Icons.User size={36} />
</Avatar>
<div class="relative flex flex-1 gap-2">
<div class="relative flex-1">
<div class="truncate text-left font-semibold">
{persona.name}
</div>
{#if persona.description}
<div class="text-muted-foreground line-clamp-2 text-left text-xs">
{persona.description}
</div>
{/if}
</div>
</div>
{/snippet}
{#snippet controls()}
{#if showControls && (onEdit || onDelete)}
<div class="flex flex-col gap-4">
{#if onEdit}
<button
class="btn btn-sm text-primary-500 p-2"
onclick={handleEditClick}
title="Edit Persona"
>
<Icons.Edit size={16} />
</button>
{/if}
{#if onDelete}
<button
class="btn btn-sm text-error-500 p-2"
onclick={handleDeleteClick}
title="Delete Persona"
>
<Icons.Trash2 size={16} />
</button>
{/if}
</div>
{/if}
{/snippet}
</SidebarListItem>

View file

@ -126,7 +126,6 @@
entry: SelectWorldLoreEntry
warn?: boolean
}): boolean {
if (!entry.name.trim()) {
if (warn) {
toaster.error({ title: "Name is required" })
@ -516,7 +515,6 @@
<LoreContentField
bind:content={entry.content}
bind:lorebookBindingList
/>
</div>
<div>
@ -549,41 +547,44 @@
</summary>
<div class="mt-2 flex flex-col gap-2">
<div class="flex w-full justify-between">
<span>Use Regex</span>
<label for="useRegex-{entry.id}">Use Regex</label>
<Switch
name="useRegex"
label="Use Regex"
name="useRegex-{entry.id}"
checked={entry.useRegex || false}
onCheckedChange={(e) =>
(entry.useRegex = e.checked)}
aria-labelledby="useRegex-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">
<span>Case Sensitive</span>
<label for="caseSensitive-{entry.id}">Case Sensitive</label>
<Switch
name="caseSensitive"
name="caseSensitive-{entry.id}"
checked={entry.caseSensitive}
onCheckedChange={(e) =>
(entry.caseSensitive =
e.checked)}
aria-labelledby="caseSensitive-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">
<span>Pinned</span>
<label for="constant-{entry.id}">Pinned</label>
<Switch
name="constant"
name="constant-{entry.id}"
checked={entry.constant}
onCheckedChange={(e) =>
(entry.constant = e.checked)}
aria-labelledby="constant-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">
<span>Enabled</span>
<label for="enabled-{entry.id}">Enabled</label>
<Switch
name="enabled"
name="enabled-{entry.id}"
checked={entry.enabled}
onCheckedChange={(e) =>
(entry.enabled = e.checked)}
aria-labelledby="enabled-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">

View file

@ -4,6 +4,15 @@
import * as skio from "sveltekit-io"
import { toaster } from "$lib/client/utils/toaster"
import { z } from "zod"
// Zod validation schema
const lorebookSchema = z.object({
name: z.string().min(1, "Name is required").trim(),
description: z.string().optional()
})
type ValidationErrors = Record<string, string>
interface Props {
lorebookId: number // ID of the lorebook to edit
@ -14,22 +23,108 @@
const socket = skio.get()
// Tag-related state
let tagsList: SelectTag[] = $state([])
let tagSearchInput = $state("")
let showTagSuggestions = $state(false)
let editLorebook: Sockets.Lorebook.Response["lorebook"] | undefined =
$state()
let originalLorebook: Sockets.Lorebook.Response["lorebook"] | undefined =
$state()
let validationErrors: ValidationErrors = $state({})
let isLoading = $state(true)
let loadError = $state("")
$effect(() => {
hasUnsavedChanges =
JSON.stringify(editLorebook) !== JSON.stringify(originalLorebook)
})
// Filtered tags for suggestions
let filteredTags = $derived.by(() => {
if (!tagSearchInput)
return tagsList.filter(
(tag) =>
!(editLorebook?.tags || []).some(
(selectedTag) =>
selectedTag.toLowerCase() === tag.name.toLowerCase()
)
)
return tagsList.filter(
(tag) =>
tag.name.toLowerCase().includes(tagSearchInput.toLowerCase()) &&
!(editLorebook?.tags || []).some(
(selectedTag) =>
selectedTag.toLowerCase() === tag.name.toLowerCase()
)
)
})
// Tag helper functions
function addTag(tagName: string) {
const trimmedName = tagName.trim()
if (!trimmedName || !editLorebook) return
// Check for case-insensitive duplicates
const isDuplicate = (editLorebook.tags || []).some(
(existingTag) =>
existingTag.toLowerCase() === trimmedName.toLowerCase()
)
if (isDuplicate) return
editLorebook.tags = [...(editLorebook.tags || []), trimmedName]
tagSearchInput = ""
showTagSuggestions = false
}
function removeTag(tagName: string) {
if (editLorebook) {
editLorebook.tags = (editLorebook.tags || []).filter(
(tag) => tag !== tagName
)
}
}
function handleTagInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && tagSearchInput.trim()) {
e.preventDefault()
addTag(tagSearchInput)
} else if (e.key === "Escape") {
showTagSuggestions = false
}
}
function handleSave() {
if (!validateForm()) return
const updateReq: Sockets.UpdateLorebook.Call = {
lorebook: editLorebook!
}
socket.emit("updateLorebook", updateReq)
}
function validateForm(): boolean {
if (!editLorebook) return false
const result = lorebookSchema.safeParse({
name: editLorebook.name,
description: editLorebook.description
})
if (result.success) {
validationErrors = {}
return true
} else {
const errors: ValidationErrors = {}
result.error.errors.forEach((error) => {
if (error.path.length > 0) {
errors[error.path[0] as string] = error.message
}
})
validationErrors = errors
return false
}
}
function handleCancel() {
editLorebook = { ...originalLorebook! }
}
@ -39,12 +134,33 @@
if (msg.lorebook && msg.lorebook.id === lorebookId) {
editLorebook = { ...msg.lorebook }
originalLorebook = { ...msg.lorebook }
isLoading = false
loadError = ""
} else {
loadError = "Lorebook not found"
isLoading = false
}
await tick() // Force state to update
})
socket.on("updateLorebook", async (msg: Sockets.Lorebook.Response) => {
toaster.success({title:"Lorebook updated successfully"})
if (msg.lorebook && msg.lorebook.id === lorebookId) {
// Update both editLorebook and originalLorebook to reflect the save
editLorebook = { ...msg.lorebook }
originalLorebook = { ...msg.lorebook }
toaster.success({
title: "Lorebook Updated",
description: `Lorebook "${msg.lorebook.name}" updated successfully.`
})
}
})
socket.on("tagsList", (msg: any) => {
tagsList = msg.tagsList || []
})
// Load tags list
socket.emit("tagsList", {})
const lorebookReq: Sockets.Lorebook.Call = { id: lorebookId }
socket.emit("lorebook", lorebookReq)
})
@ -52,10 +168,25 @@
onDestroy(() => {
socket.off("lorebook")
socket.off("updateLorebook")
socket.off("tagsList")
})
</script>
{#if editLorebook}
{#if isLoading}
<div class="flex items-center justify-center p-4">
<div class="text-center">
<Icons.Loader size={24} class="animate-spin mx-auto mb-2" />
<p>Loading lorebook...</p>
</div>
</div>
{:else if loadError}
<div class="flex items-center justify-center p-4">
<div class="text-center">
<Icons.AlertTriangle size={24} class="mx-auto mb-2 text-error-500" />
<p class="text-error-500">{loadError}</p>
</div>
</div>
{:else if editLorebook}
<div class="flex flex-col gap-6">
<div class="flex gap-2">
<button
@ -78,12 +209,25 @@
<label class="font-semibold" for="lorebookName">Name*</label>
<input
id="lorebookName"
class="input input-lg w-full"
class="input input-lg w-full {validationErrors.name
? 'border-red-500'
: ''}"
type="text"
placeholder="Enter lorebook name"
bind:value={editLorebook.name}
required
oninput={() => {
if (validationErrors.name) {
const { name, ...rest } = validationErrors
validationErrors = rest
}
}}
/>
{#if validationErrors.name}
<p class="mt-1 text-sm text-red-500" role="alert">
{validationErrors.name}
</p>
{/if}
</div>
<div>
<label class="font-semibold" for="lorebookDescription">
@ -97,5 +241,72 @@
rows={2}
></textarea>
</div>
<!-- Tags Section -->
<div>
<label class="font-semibold" for="tagInput">Tags</label>
<div class="relative">
<input
id="tagInput"
type="text"
bind:value={tagSearchInput}
class="input w-full"
placeholder="Add a tag..."
onfocus={() => (showTagSuggestions = true)}
onblur={() =>
setTimeout(() => (showTagSuggestions = false), 200)}
onkeydown={handleTagInputKeydown}
/>
<!-- Tag suggestions dropdown -->
{#if showTagSuggestions && filteredTags.length > 0}
<div
class="bg-surface-100-900 absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded-lg border shadow-lg"
>
{#each filteredTags as tag}
<button
type="button"
class="hover:bg-surface-200-800 w-full px-3 py-2 text-left transition-colors"
onclick={() => addTag(tag.name)}
>
<span
class="chip mr-2 {tag.colorPreset ||
'preset-filled-primary-500'}"
>
{tag.name}
</span>
{#if tag.description}
<span class="text-muted-foreground text-sm">
- {tag.description}
</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Selected tags display -->
{#if editLorebook.tags && editLorebook.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-2">
{#each editLorebook.tags as tagName}
{@const tag = tagsList.find((t) => t.name === tagName)}
<button
type="button"
class="chip {tag?.colorPreset ||
'preset-filled-primary-500'} group relative"
onclick={() => removeTag(tagName)}
title="Click to remove tag"
>
{tagName}
<Icons.X
size={14}
class="ml-1 opacity-60 group-hover:opacity-100"
/>
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}

View file

@ -32,7 +32,7 @@
const DefaultHistoryEntry: InsertHistoryEntry = {
year: 1,
month: null,
day:null,
day: null,
content: "",
keys: "",
useRegex: false,
@ -54,9 +54,13 @@
let isReordering = $state(false)
let deleteEntryId: number | null = $state(null)
let showDeleteConfirmModal = $state(false)
let filteredEntries: SelectHistoryEntry[] = $derived.by(() => getFilteredEntries())
let filteredEntries: SelectHistoryEntry[] = $derived.by(() =>
getFilteredEntries()
)
let maxDateValue: number = $derived.by(() => {
return filteredEntries.length ? getEntryDateValue(filteredEntries[0]) : 0
return filteredEntries.length
? getEntryDateValue(filteredEntries[0])
: 0
})
$effect(() => {
@ -341,8 +345,16 @@
})
// --- Reactive sorted/filter logic for display and current date ---
$effect(() => {filteredEntries = getFilteredEntries().slice().sort((a, b) => getEntryDateValue(b) - getEntryDateValue(a))})
$effect(() => {maxDateValue = filteredEntries.length ? getEntryDateValue(filteredEntries[0]) : 0})
$effect(() => {
filteredEntries = getFilteredEntries()
.slice()
.sort((a, b) => getEntryDateValue(b) - getEntryDateValue(a))
})
$effect(() => {
maxDateValue = filteredEntries.length
? getEntryDateValue(filteredEntries[0])
: 0
})
</script>
{#if isReady}
@ -394,7 +406,9 @@
{@const isEditing = entry.id in editEntriesData || !entry.id}
{@const isFirstWithMaxDate =
getEntryDateValue(entry) === maxDateValue &&
filteredEntries.findIndex(e => getEntryDateValue(e) === maxDateValue) === filteredEntries.indexOf(entry)}
filteredEntries.findIndex(
(e) => getEntryDateValue(e) === maxDateValue
) === filteredEntries.indexOf(entry)}
{#key entry}
{#if isEditing}
<!-- Edit mode: show the form -->
@ -528,41 +542,44 @@
</summary>
<div class="mt-2 flex flex-col gap-2">
<div class="flex w-full justify-between">
<span>Use Regex</span>
<label for="useRegex-{entry.id}">Use Regex</label>
<Switch
name="useRegex"
label="Use Regex"
name="useRegex-{entry.id}"
checked={entry.useRegex || false}
onCheckedChange={(e) =>
(entry.useRegex = e.checked)}
aria-labelledby="useRegex-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">
<span>Case Sensitive</span>
<label for="caseSensitive-{entry.id}">Case Sensitive</label>
<Switch
name="caseSensitive"
name="caseSensitive-{entry.id}"
checked={entry.caseSensitive}
onCheckedChange={(e) =>
(entry.caseSensitive =
e.checked)}
aria-labelledby="caseSensitive-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">
<span>Pinned</span>
<label for="constant-{entry.id}">Pinned</label>
<Switch
name="constant"
name="constant-{entry.id}"
checked={entry.constant}
onCheckedChange={(e) =>
(entry.constant = e.checked)}
aria-labelledby="constant-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">
<span>Enabled</span>
<label for="enabled-{entry.id}">Enabled</label>
<Switch
name="enabled"
name="enabled-{entry.id}"
checked={entry.enabled}
onCheckedChange={(e) =>
(entry.enabled = e.checked)}
aria-labelledby="enabled-{entry.id}"
/>
</div>
</div>
@ -596,9 +613,7 @@
<strong>Date:</strong>
{entry.year}{entry.month
? `-${entry.month}`
: ""}{entry.day
? `-${entry.day}`
: ""}
: ""}{entry.day ? `-${entry.day}` : ""}
{#if entry.year === 0 && entry.month === 0 && entry.day === 0}
<span
class="text-tertiary-500 ml-2 text-sm"
@ -758,7 +773,10 @@
<DeleteLorebookEntryConfirmModal
open={showDeleteConfirmModal}
onOpenChange={(e) => {showDeleteConfirmModal = e.open; deleteEntryId = null}}
onOpenChange={(e) => {
showDeleteConfirmModal = e.open
deleteEntryId = null
}}
onConfirm={onDeleteConfirm}
onCancel={onDeleteCancel}
/>
/>

View file

@ -5,7 +5,7 @@
import LorebookBindingTag from "../../utils/tiptapLorebookBindingTag"
import * as Icons from "@lucide/svelte"
import { Popover } from "@skeletonlabs/skeleton-svelte"
import Placeholder from '@tiptap/extension-placeholder'
import Placeholder from "@tiptap/extension-placeholder"
import LegacyTag from "$lib/client/utils/tiptapLegacyTag"
import type { EditorView } from "prosemirror-view"
@ -16,7 +16,7 @@
let { content = $bindable(), lorebookBindingList = $bindable() }: Props =
$props()
let editor: Editor
let editorEl: HTMLDivElement
let isBold = $state(false)
@ -51,56 +51,66 @@
}
function getContentWithCharTags(editor: Editor): string {
if (!editor) return "";
const doc = editor.state.doc;
let result = "";
if (!editor) return ""
const doc = editor.state.doc
let result = ""
doc.descendants((node) => {
if (node.type.name === "LorebookBindingTag") {
result += `{char:${node.attrs.id}}`;
// Use the correct {{char:#}} syntax (double braces)
result += `{{char:${node.attrs.id}}}`
} else if (node.type.name === "legacyTag") {
result += node.attrs.original
} else if (node.isText) {
result += node.text;
result += node.text
} else if (node.isBlock) {
result += "\n";
result += "\n"
}
return true;
});
return result;
return true
})
return result
}
// Helper: parse {char:N} and legacy tags in plain text to Tiptap doc JSON
// Helper: parse {{char:N}} and double-brace legacy tags in plain text to Tiptap doc JSON
function parseCharTagsToTiptapDoc(text: string) {
const parts = [];
let lastIndex = 0;
// Regex for {char:N} and legacy tags ({user}, {char}, {persona}, {character})
const regex = /\{char:(\d+)\}|\{(user|char|persona|character)\}/g;
let match;
const parts = []
let lastIndex = 0
// Regex for {{char:N}} and double-brace legacy tags only ({{user}}, {{char}}, {{persona}}, {{character}})
const regex = /\{\{char:(\d+)\}\}|\{\{(user|char|persona|character)\}\}/g
let match
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', text: text.slice(lastIndex, match.index) });
parts.push({
type: "text",
text: text.slice(lastIndex, match.index)
})
}
if (match[1]) {
// {char:N}
parts.push({ type: 'LorebookBindingTag', attrs: { id: match[1] } });
// {{char:N}} - numbered binding syntax
parts.push({
type: "LorebookBindingTag",
attrs: { id: match[1] }
})
} else if (match[2]) {
// legacy tags: {user}, {char}, {persona}, {character}
parts.push({ type: 'legacyTag', attrs: { tag: `{${match[2]}}`, original: `{${match[2]}}` } });
// {{user}}, {{char}}, etc. - double-brace legacy tag syntax
parts.push({
type: "legacyTag",
attrs: { tag: `{{${match[2]}}}`, original: `{{${match[2]}}}` }
})
}
lastIndex = match.index + match[0].length;
lastIndex = match.index + match[0].length
}
if (lastIndex < text.length) {
parts.push({ type: 'text', text: text.slice(lastIndex) });
parts.push({ type: "text", text: text.slice(lastIndex) })
}
return {
type: 'doc',
type: "doc",
content: [
{
type: 'paragraph',
type: "paragraph",
content: parts
}
]
};
}
}
function forceRawContentCopy(view: EditorView, arg1: () => string) {
@ -123,10 +133,10 @@
extensions: [
StarterKit,
LorebookBindingTag.configure({ getLabel, getCharType }),
LegacyTag.configure({}),
// Placeholder.configure({
// placeholder: ({ node }) => "A subterranean metropolis carved into the bones of a long-dead titan..."
// }),
LegacyTag.configure({})
// Placeholder.configure({
// placeholder: ({ node }) => "A subterranean metropolis carved into the bones of a long-dead titan..."
// }),
],
onUpdate: ({ editor }) => {
content = getContentWithCharTags(editor)
@ -141,9 +151,7 @@
})
</script>
<div
class=""
>
<div class="">
<div class="tiptap-toolbar mb-1">
<Popover
open={addBindingOpenState}
@ -174,7 +182,9 @@
class:preset-filled-surface-500={!!binding.personaId}
class:preset-filled-warning-500={!char}
onclick={() => {
editor.commands.insertLorebookBindingTag(binding.binding)
editor.commands.insertLorebookBindingTag(
binding.binding
)
addBindingOpenState = false
}}
title={char
@ -216,7 +226,7 @@
<div
bind:this={editorEl}
class="tiptap-content preset-filled-surface-200-800 rounded-lg"
placeholder="A subterranean metropolis carved into the bones of a long-dead titan..."
placeholder="A subterranean metropolis carved into the bones of a long-dead titan..."
></div>
</div>
@ -224,6 +234,5 @@
@reference "tailwindcss";
:global {
}
</style>

View file

@ -25,7 +25,7 @@
const DefaultWorldEntry: InsertWorldLoreEntry = {
name: "",
content: "",
keys: [],
keys: "",
useRegex: false,
caseSensitive: false,
constant: false,
@ -127,7 +127,6 @@
entry: SelectWorldLoreEntry
warn?: boolean
}): boolean {
if (!entry.name.trim()) {
if (warn) {
toaster.error({ title: "Name is required" })
@ -143,7 +142,6 @@
}: {
entry: SelectWorldLoreEntry | (InsertWorldLoreEntry & { _uuid: string })
}) {
if (!entryIsValid({ entry, warn: true })) {
return
}
@ -242,7 +240,6 @@
}: {
entries: Sockets.WorldLoreEntryList.Response["worldLoreEntryList"]
}) {
console.log("Reordering entries:", $state.snapshot(entries))
// Map id's to positions
const positionMap: Sockets.UpdateWorldLoreEntryPositions.Call["positions"] =
[]
@ -498,41 +495,44 @@
</summary>
<div class="mt-2 flex flex-col gap-2">
<div class="flex w-full justify-between">
<span>Use Regex</span>
<label for="useRegex-{entry.id}">Use Regex</label>
<Switch
name="useRegex"
label="Use Regex"
name="useRegex-{entry.id}"
checked={entry.useRegex || false}
onCheckedChange={(e) =>
(entry.useRegex = e.checked)}
aria-labelledby="useRegex-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">
<span>Case Sensitive</span>
<label for="caseSensitive-{entry.id}">Case Sensitive</label>
<Switch
name="caseSensitive"
name="caseSensitive-{entry.id}"
checked={entry.caseSensitive}
onCheckedChange={(e) =>
(entry.caseSensitive =
e.checked)}
aria-labelledby="caseSensitive-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">
<span>Pinned</span>
<label for="constant-{entry.id}">Pinned</label>
<Switch
name="constant"
name="constant-{entry.id}"
checked={entry.constant}
onCheckedChange={(e) =>
(entry.constant = e.checked)}
aria-labelledby="constant-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">
<span>Enabled</span>
<label for="enabled-{entry.id}">Enabled</label>
<Switch
name="enabled"
name="enabled-{entry.id}"
checked={entry.enabled}
onCheckedChange={(e) =>
(entry.enabled = e.checked)}
aria-labelledby="enabled-{entry.id}"
/>
</div>
<div class="flex w-full justify-between">

View file

@ -0,0 +1,908 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import * as skio from "sveltekit-io"
import { onDestroy, onMount } from "svelte"
import { z } from "zod"
import Avatar from "../Avatar.svelte"
interface Props {
open: boolean
onOpenChange?: (e: { open: boolean }) => void
}
let { open = $bindable(), onOpenChange }: Props = $props()
const socket = skio.get()
// Character data interface
interface CharacterData {
name: string
nickname: string
avatar: string
description: string
personality: string
firstMessage: string
_avatarFile?: File | undefined
_avatar?: string
}
// Zod validation schema (same as CharacterForm but only required fields)
const characterSchema = z.object({
name: z.string().min(1, "Name is required").trim(),
nickname: z.string().optional(),
description: z.string().min(1, "Description is required").trim(),
personality: z.string().optional(),
firstMessage: z.string().optional()
})
type ValidationErrors = Record<string, string>
// State
let currentStep = $state(0)
let characterData: CharacterData = $state({
name: "",
nickname: "",
avatar: "",
description: "",
personality: "",
firstMessage: "",
_avatarFile: undefined,
_avatar: ""
})
let validationErrors: ValidationErrors = $state({})
let showCancelConfirmation = $state(false)
// Step definitions
const steps = [
{ title: "Name", canSkip: false },
{ title: "Avatar", canSkip: true },
{ title: "Description", canSkip: false },
{ title: "Personality", canSkip: true },
{ title: "First Message", canSkip: true }
]
// Validation functions
function validateCurrentStep(): boolean {
const step = steps[currentStep]
// Only validate required steps
if (!step.canSkip) {
if (currentStep === 0) {
// Step 1: Name is required
if (!characterData.name.trim()) {
validationErrors = { name: "Name is required" }
return false
}
} else if (currentStep === 2) {
// Step 3: Description is required
if (!characterData.description.trim()) {
validationErrors = {
description: "Description is required"
}
return false
}
}
}
validationErrors = {}
return true
}
function validateFinalForm(): boolean {
const result = characterSchema.safeParse(characterData)
if (result.success) {
validationErrors = {}
return true
} else {
const errors: ValidationErrors = {}
result.error.errors.forEach((error) => {
if (error.path.length > 0) {
errors[error.path[0] as string] = error.message
}
})
validationErrors = errors
return false
}
}
// Avatar handling
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement | null
if (!input || !input.files || input.files.length === 0) return
const file = input.files[0]
if (!file) return
// Set preview
const previewReader = new FileReader()
previewReader.onload = (ev2) => {
characterData._avatar = ev2.target?.result as string
}
previewReader.readAsDataURL(file)
// Store file for later upload
characterData._avatarFile = file
}
// Navigation functions
function handleNext() {
// Validate current step if it's required
if (!steps[currentStep].canSkip && !validateCurrentStep()) {
return
}
if (currentStep < steps.length - 1) {
currentStep++
}
}
function handlePrevious() {
if (currentStep > 0) {
currentStep--
}
}
function handleSave() {
if (!validateFinalForm()) {
// Find the first step with validation errors and go to it
if (validationErrors.name) {
currentStep = 0
} else if (validationErrors.description) {
currentStep = 2
}
return
}
// Prepare character data for creation
const newCharacter = {
...characterData,
alternateGreetings: [],
exampleDialogues: [],
creatorNotes: "",
creatorNotesMultilingual: {},
groupOnlyGreetings: [],
postHistoryInstructions: "",
isFavorite: false,
lorebookId: null,
scenario: ""
}
const avatarFile = newCharacter._avatarFile
delete newCharacter._avatarFile
delete newCharacter._avatar
socket.emit("createCharacter", {
character: newCharacter,
avatarFile
})
}
function resetForm() {
// Reset form data
characterData = {
name: "",
nickname: "",
avatar: "",
description: "",
personality: "",
firstMessage: "",
_avatarFile: undefined,
_avatar: ""
}
validationErrors = {}
currentStep = 0
open = false
}
function handleCancel() {
if (hasUnsavedData) {
showCancelConfirmation = true
} else {
resetForm()
}
}
function handleCancelConfirm() {
showCancelConfirmation = false
resetForm()
}
function handleCancelCancel() {
showCancelConfirmation = false
}
function clearValidationError(field: string) {
if (validationErrors[field]) {
const { [field]: removed, ...rest } = validationErrors
validationErrors = rest
}
}
// Computed properties
let isLastStep = $derived(currentStep === steps.length - 1)
let isFirstStep = $derived(currentStep === 0)
let canProceedToNext = $derived(() => {
// Always allow proceeding on optional steps
if (steps[currentStep].canSkip) return true
// For required steps, validate the current step
return validateCurrentStep()
})
// Check if any fields are populated (has unsaved data)
let hasUnsavedData = $derived(
characterData.name.trim() !== "" ||
characterData.nickname.trim() !== "" ||
characterData.description.trim() !== "" ||
characterData.personality.trim() !== "" ||
characterData.firstMessage.trim() !== "" ||
!!characterData._avatarFile
)
onMount(() => {
socket.on("createCharacter", (res: any) => {
if (res.character) {
resetForm() // This will close the modal and reset data
}
})
})
onDestroy(() => {
socket.off("createCharacter")
})
</script>
<Modal
{open}
onOpenChange={(e) => {
if (!e.open && hasUnsavedData && !showCancelConfirmation) {
// If trying to close and has unsaved data, show confirmation
showCancelConfirmation = true
return
}
// Otherwise allow normal close behavior
onOpenChange?.(e)
}}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
{#if showCancelConfirmation}
<!-- Cancel Confirmation View -->
<header class="flex items-center justify-between">
<h2 class="h2">Confirm Action</h2>
<button
class="btn btn-sm preset-tonal-surface"
onclick={handleCancelCancel}
aria-label="Go back to editing"
>
<Icons.X size={16} />
</button>
</header>
<article class="flex min-h-[200px] items-center justify-center">
<div class="space-y-4 text-center">
<div class="text-warning-500 mb-4">
<Icons.AlertTriangle size={48} class="mx-auto" />
</div>
<h3 class="h3">Discard Character?</h3>
<p class="max-w-md text-sm opacity-75">
You have unsaved changes to your character. Are you sure
you want to discard them and close the creator?
</p>
</div>
</article>
<footer class="flex justify-end gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handleCancelCancel}
>
<Icons.ArrowLeft size={16} />
Keep Editing
</button>
<button
class="btn preset-filled-error-500"
onclick={handleCancelConfirm}
>
<Icons.Trash2 size={16} />
Discard Changes
</button>
</footer>
{:else}
<!-- Normal Form View -->
<header class="flex items-center justify-between">
<div>
<h2 class="h2">Create Character</h2>
<p class="text-sm opacity-60">
Step {currentStep + 1} of {steps.length}: {steps[
currentStep
].title}
</p>
</div>
<button
class="btn btn-sm preset-tonal-surface"
onclick={handleCancel}
aria-label="Close character creator"
>
<Icons.X size={16} />
</button>
</header>
<!-- Progress indicator -->
<div class="flex gap-2">
{#each steps as _, index}
<div
class="h-2 flex-1 rounded-full {index <= currentStep
? 'bg-primary-500'
: 'bg-surface-400'}"
></div>
{/each}
</div>
<!-- Step content -->
<article class="min-h-[400px]">
{#if currentStep === 0}
<!-- Step 1: Name & Nickname -->
<div class="space-y-6">
<div class="space-y-2 text-center">
<h3 class="h3">Let's start with the basics</h3>
</div>
<div class="space-y-4">
<!-- Name Field -->
<div class="space-y-2">
<label
class="flex gap-1 font-semibold"
for="stepName"
>
Name*
<span
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
title="This field will be visible in prompts"
aria-label="This field will be visible in prompts"
>
<Icons.ScanEye
size={16}
class="relative top-[1px] inline"
aria-hidden="true"
/>
</span>
</label>
<input
id="stepName"
type="text"
bind:value={characterData.name}
class="input {validationErrors.name
? 'border-red-500 focus:border-red-500'
: ''}"
placeholder="Enter character name..."
aria-required="true"
aria-invalid={validationErrors.name
? "true"
: "false"}
aria-describedby={validationErrors.name
? "name-error"
: undefined}
oninput={() => clearValidationError("name")}
/>
{#if validationErrors.name}
<p
class="mt-1 text-sm text-red-500"
id="name-error"
role="alert"
>
{validationErrors.name}
</p>
{/if}
</div>
<!-- Nickname Field -->
<div class="space-y-2">
<label
class="flex gap-1 font-semibold"
for="stepNickname"
>
Nickname (Optional)
<span
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
title="This field will be visible in prompts"
aria-label="This field will be visible in prompts"
>
<Icons.ScanEye
size={16}
class="relative top-[1px] inline"
aria-hidden="true"
/>
</span>
</label>
<input
id="stepNickname"
type="text"
bind:value={characterData.nickname}
class="input"
placeholder="Enter nickname (optional)..."
aria-label="Character nickname"
/>
</div>
</div>
<!-- Example -->
<div class="bg-primary-500/10 rounded-lg p-4">
<h4
class="mb-3 flex items-center gap-2 text-sm font-semibold"
>
<Icons.Sparkles
size={16}
class="text-primary-500"
/>
Example & Guidelines
</h4>
<div class="space-y-3">
<div class="space-y-1 text-sm opacity-75">
<p>
<strong>Name:</strong>
"Dr. John Watson"
</p>
<p>
<strong>Nickname:</strong>
"Watson"
</p>
</div>
<div
class="border-primary-500/20 space-y-2 border-t pt-3 text-xs opacity-60"
>
<p>
<strong>Name:</strong>
The character's full or primary name (e.g.,
"Elizabeth Bennet", "Sherlock Holmes")
</p>
<p>
<strong>Nickname:</strong>
A shorter, informal name or title (e.g.,
"Lizzy", "Detective Holmes"). If provided,
the nickname will be used in conversations
and prompts instead of the full name.
</p>
</div>
</div>
</div>
</div>
{:else if currentStep === 1}
<!-- Step 2: Avatar -->
<div class="space-y-6">
<div class="space-y-2 text-center">
<h3 class="h3">Add an avatar</h3>
<p class="text-sm opacity-75">
Upload an image to represent your character.
This step is optional but helps personalize your
character.
</p>
</div>
<div class="flex items-center gap-6">
<!-- Avatar Preview -->
<div class="flex-shrink-0">
<Avatar
src={characterData._avatar ||
characterData.avatar}
char={characterData}
/>
</div>
<!-- Upload Area -->
<div class="flex-1 space-y-3">
<div
class="flex w-full items-center justify-center"
>
<label
for="avatar-upload"
class="flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-800"
>
<div
class="flex flex-col items-center justify-center"
>
<Icons.Upload
class="mb-3 h-8 w-8 text-gray-500 dark:text-gray-400"
/>
<p
class="mb-2 text-sm text-gray-500 dark:text-gray-400"
>
<span class="font-semibold">
Click to upload
</span>
or drag and drop
</p>
<p
class="text-xs text-gray-500 dark:text-gray-400"
>
PNG, JPG or GIF
</p>
</div>
<input
id="avatar-upload"
type="file"
class="hidden"
accept="image/*"
onchange={handleAvatarChange}
/>
</label>
</div>
{#if characterData._avatarFile}
<button
type="button"
class="btn btn-sm preset-tonal-error w-full"
onclick={() => {
characterData._avatarFile =
undefined
characterData._avatar = ""
}}
>
<Icons.Trash2 size={16} />
Remove Image
</button>
{/if}
<p class="text-xs opacity-60">
Supported formats: JPG, PNG, GIF. The image
will be resized automatically to fit the
interface.
</p>
</div>
</div>
<!-- Example -->
<div class="bg-primary-500/10 rounded-lg p-4">
<h4
class="mb-2 flex items-center gap-2 text-sm font-semibold"
>
<Icons.Sparkles
size={16}
class="text-primary-500"
/>
Tip
</h4>
<p class="text-sm opacity-75">
A good avatar helps bring your character to life
and makes conversations more engaging. You can
always change it later.
</p>
</div>
</div>
{:else if currentStep === 2}
<!-- Step 3: Description -->
<div class="space-y-6">
<div class="space-y-2 text-center">
<h3 class="h3">Describe your character</h3>
<p class="text-sm opacity-75">
Write a description that captures your
character's appearance, background, and key
traits. This is essential for the AI to
understand your character.
</p>
</div>
<div class="space-y-2">
<label
class="flex gap-1 font-semibold"
for="stepDescription"
>
Description*
<span
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
title="This field will be visible in prompts"
aria-label="This field will be visible in prompts"
>
<Icons.ScanEye
size={16}
class="relative top-[1px] inline"
aria-hidden="true"
/>
</span>
</label>
<textarea
id="stepDescription"
rows="8"
bind:value={characterData.description}
class="input {validationErrors.description
? 'border-red-500 focus:border-red-500'
: ''}"
placeholder="Describe your character..."
aria-required="true"
aria-invalid={validationErrors.description
? "true"
: "false"}
aria-describedby={validationErrors.description
? "description-error"
: undefined}
oninput={() =>
clearValidationError("description")}
></textarea>
{#if validationErrors.description}
<p
class="mt-1 text-sm text-red-500"
id="description-error"
role="alert"
>
{validationErrors.description}
</p>
{/if}
<div class="space-y-2 text-xs opacity-60">
<p>
<strong>Include:</strong>
Physical appearance, age, background, occupation,
or role
</p>
<p>
<strong>Avoid:</strong>
Personality traits (save for the next step),
specific scenarios, or conversations
</p>
</div>
</div>
<!-- Example -->
<div class="bg-primary-500/10 rounded-lg p-4">
<h4
class="mb-2 flex items-center gap-2 text-sm font-semibold"
>
<Icons.Sparkles
size={16}
class="text-primary-500"
/>
Example
</h4>
<p class="text-sm opacity-75">
"Dr. John Watson is a former army doctor in his
late 30s with short blonde hair and kind blue
eyes. He's practical, loyal, and brave, often
serving as the moral compass to his brilliant
but eccentric flatmate. Having served in
Afghanistan, he brings medical expertise and
military discipline to their adventures."
</p>
</div>
</div>
{:else if currentStep === 3}
<!-- Step 4: Personality -->
<div class="space-y-6">
<div class="space-y-2 text-center">
<h3 class="h3">Define their personality</h3>
<p class="text-sm opacity-75">
Describe how your character thinks, feels, and
behaves. This step is optional but helps create
more authentic interactions.
</p>
</div>
<div class="space-y-2">
<label
class="flex gap-1 font-semibold"
for="stepPersonality"
>
Personality (Optional)
<span
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
title="This field will be visible in prompts"
aria-label="This field will be visible in prompts"
>
<Icons.ScanEye
size={16}
class="relative top-[1px] inline"
aria-hidden="true"
/>
</span>
</label>
<textarea
id="stepPersonality"
rows="6"
bind:value={characterData.personality}
class="input"
placeholder="Describe their personality traits and quirks..."
aria-label="Character personality"
></textarea>
<div class="space-y-2 text-xs opacity-60">
<p>
<strong>Include:</strong>
Personality traits, values, quirks, speaking
style, emotional tendencies
</p>
<p>
Is your character "optimistic and curious",
"sarcastic but caring", or "methodical and
analytical"?
</p>
<p>
This helps the AI understand how your
character should behave and respond in
conversations.
</p>
</div>
</div>
<!-- Example -->
<div class="bg-primary-500/10 rounded-lg p-4">
<h4
class="mb-2 flex items-center gap-2 text-sm font-semibold"
>
<Icons.Sparkles
size={16}
class="text-primary-500"
/>
Example
</h4>
<p class="text-sm opacity-75">
"Watson is patient and methodical, with a dry
sense of humor. He's fiercely loyal to his
friends and has a strong moral compass. While
not as brilliant as Holmes, he's practical and
grounded, often providing the emotional
intelligence that Holmes lacks. He tends to be
modest about his own abilities."
</p>
</div>
</div>
{:else if currentStep === 4}
<!-- Step 5: First Message -->
<div class="space-y-6">
<div class="space-y-2 text-center">
<h3 class="h3">Set the opening scene</h3>
<p class="text-sm opacity-75">
This is technically optional, but <strong>
highly recommended
</strong>
. The first message teaches the AI how your character
acts, speaks, and responds.
</p>
</div>
<div
class="bg-warning-500/10 border-warning-500/20 rounded-lg border p-4"
>
<div class="flex items-start gap-3">
<Icons.Lightbulb
size={20}
class="text-warning-500 mt-0.5 flex-shrink-0"
/>
<div class="space-y-2 text-sm">
<p
class="text-warning-700 dark:text-warning-300 font-semibold"
>
Why this matters:
</p>
<p>
The first message is like a <strong>
writing sample
</strong>
that shows the AI your character's voice,
tone, and behavior patterns. It significantly
improves response quality.
</p>
</div>
</div>
</div>
<div class="space-y-2">
<label class="font-semibold" for="stepFirstMessage">
First Message (Optional but Recommended)
</label>
<textarea
id="stepFirstMessage"
rows="6"
bind:value={characterData.firstMessage}
class="input"
placeholder="Write their opening message..."
aria-label="Character first message"
></textarea>
<p class="text-xs opacity-60">
This serves as a <strong>
training example
</strong>
that helps the AI understand your character's communication
style and behavior patterns.
</p>
</div>
<!-- Example -->
<div class="bg-primary-500/10 rounded-lg p-4">
<h4
class="mb-2 flex items-center gap-2 text-sm font-semibold"
>
<Icons.Sparkles
size={16}
class="text-primary-500"
/>
Example with Key Elements
</h4>
<div class="space-y-3">
<p class="text-sm italic opacity-75">
"*Dr. Watson looks up from his medical
journal, adjusting his reading glasses with
a warm smile* Ah, good to see you! I was
just reviewing some fascinating case notes.
Please, have a seat and tell me - what
brings you to Baker Street today?"
</p>
<div
class="border-primary-500/20 border-t pt-3 text-xs opacity-60"
>
<p class="mb-1">
<strong>
Notice how this example:
</strong>
</p>
<ul class="list-inside list-disc space-y-1">
<li>
Uses *asterisks* for actions and
descriptions
</li>
<li>
Shows personality through warm,
welcoming tone
</li>
<li>
Establishes setting (Baker Street,
medical context)
</li>
<li>
Demonstrates speaking patterns and
vocabulary
</li>
<li>
Ends with an engaging question to
continue conversation
</li>
</ul>
</div>
</div>
</div>
</div>
{/if}
</article>
<!-- Navigation -->
<footer class="flex justify-between gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handlePrevious}
disabled={isFirstStep}
>
<Icons.ChevronLeft size={16} />
Previous
</button>
<div class="flex gap-2">
{#if steps[currentStep].canSkip && !isLastStep}
<button
class="btn preset-tonal-surface"
onclick={handleNext}
>
Skip
<Icons.ChevronRight size={16} />
</button>
{/if}
{#if isLastStep}
<button
class="btn preset-filled-success-500"
onclick={handleSave}
>
<Icons.Save size={16} />
Create Character
</button>
{:else}
<button
class="btn preset-filled-primary-500"
onclick={handleNext}
disabled={!canProceedToNext}
>
Next
<Icons.ChevronRight size={16} />
</button>
{/if}
</div>
</footer>
{/if}
{/snippet}
</Modal>

View file

@ -38,11 +38,11 @@
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-h-[95dvh] relative overflow-hidden w-[50em] max-w-95dvw"
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-h-[95dvh] relative overflow-hidden w-[50em] max-w-95dvw"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="mb-2 flex items-center justify-between">
<header class="flex items-center justify-between">
<h2 class="h2">Select Character</h2>
<button
class="btn btn-sm"
@ -52,7 +52,7 @@
</button>
</header>
<input
class="input mb-4 w-full"
class="input w-full"
type="text"
placeholder="Search characters..."
bind:value={search}

View file

@ -1,43 +1,44 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean;
onOpenChange: (e: OpenChangeDetails) => void;
onConfirm: () => void;
onCancel: () => void;
}
import { Modal } from "@skeletonlabs/skeleton-svelte"
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props();
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: () => void
onCancel: () => void
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props()
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your character has unsaved changes. Are you sure you want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Discard
</button>
</footer>
{/snippet}
</Modal>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your character has unsaved changes. Are you sure you want to
discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Discard
</button>
</footer>
{/snippet}
</Modal>

View file

@ -1,41 +1,44 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean;
onOpenChange: (e: OpenChangeDetails) => void;
onConfirm: () => void;
onCancel: () => void;
}
import { Modal } from "@skeletonlabs/skeleton-svelte"
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props();
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: () => void
onCancel: () => void
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props()
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your chat has unsaved changes. Are you sure you want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>Cancel</button
>
<button class="btn preset-filled-error-500" onclick={onConfirm}>Discard</button
>
</footer>
{/snippet}
</Modal>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your chat has unsaved changes. Are you sure you want to discard
them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Discard
</button>
</footer>
{/snippet}
</Modal>

View file

@ -1,41 +1,44 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean;
onOpenChange: (e: OpenChangeDetails) => void;
onConfirm: () => void;
onCancel: () => void;
}
import { Modal } from "@skeletonlabs/skeleton-svelte"
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props();
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: () => void
onCancel: () => void
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props()
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your context configuration has unsaved changes. Are you sure you want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>Cancel</button
>
<button class="btn preset-filled-error-500" onclick={onConfirm}>Discard</button
>
</footer>
{/snippet}
</Modal>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your context configuration has unsaved changes. Are you sure you
want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Discard
</button>
</footer>
{/snippet}
</Modal>

View file

@ -1,43 +1,44 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean;
onOpenChange: (e: OpenChangeDetails) => void;
onConfirm: () => void;
onCancel: () => void;
}
import { Modal } from "@skeletonlabs/skeleton-svelte"
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props();
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: () => void
onCancel: () => void
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props()
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Are you sure you want to delete this lorebook entry? This action cannot be undone.
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Delete
</button>
</footer>
{/snippet}
</Modal>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Are you sure you want to delete this lorebook entry? This action
cannot be undone.
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Delete
</button>
</footer>
{/snippet}
</Modal>

View file

@ -0,0 +1,177 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean
selectedModelForDownload: string | null
selectedModel:
| Sockets.OllamaSearchAvailableModels.Response["models"][0]
| null
onClose: () => void
onDownload: (
modelId: string,
pullOption: { label: string; pull: string }
) => void
}
let {
open = $bindable(),
selectedModelForDownload,
selectedModel,
onClose,
onDownload
}: Props = $props()
// Helper function to extract numeric value from quantization for sorting
function getQuantizationSortValue(label: string | undefined): number {
if (!label) return 0
// Extract the main number (e.g., "Q4_K_M" -> 4, "Q8_0" -> 8)
const match = label.match(/[Qq](\d+)/)
return match ? parseInt(match[1]) : 0
}
// Sort pullOptions by quantization level (highest to lowest)
let sortedPullOptions = $derived(
// selectedModel?.pullOptions ?
// [...selectedModel.pullOptions].sort((a, b) => {
// const aValue = getQuantizationSortValue(a.label)
// const bValue = getQuantizationSortValue(b.label)
// return bValue - aValue // Descending order
// }) : []
selectedModel?.pullOptions || []
)
</script>
<Modal
{open}
onOpenChange={(e) => (open = e.open)}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl w-[50em] max-w-dvw-lg border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Select Quantization</h2>
</header>
<article class="space-y-4">
<div>
<p class="text-surface-500 mb-2 text-sm">
Choose a quantization level for <strong>
{selectedModelForDownload}
</strong>
</p>
<div class="bg-surface-200-800 rounded-lg p-3">
<p class="text-surface-600-400 text-xs">
<strong>About Quantizations:</strong>
These are compressed variants of the model that reduce file
size and memory usage while maintaining good performance.
Higher numbers (Q8) preserve more quality but use more resources,
while lower numbers (Q4) are more efficient but with slight
quality trade-offs.
</p>
</div>
</div>
{#if !selectedModel}
<div class="p-6 text-center">
<Icons.Loader2
class="mx-auto mb-4 animate-spin"
size={32}
/>
<p class="text-sm opacity-75">
Loading model information...
</p>
</div>
{:else if sortedPullOptions.length === 0}
<div class="p-6 text-center">
<Icons.AlertCircle
class="text-surface-500 mx-auto mb-4"
size={48}
/>
<p class="text-sm opacity-75">
No GGUF quantizations are available for this model.
</p>
</div>
{:else}
<div class="max-h-96 space-y-3 overflow-y-auto">
{#each sortedPullOptions as pullOption, index}
<div class="card bg-surface-200-800 p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="mb-2 flex items-center gap-2">
<h5 class="font-semibold">
{pullOption.label || "Unknown file"}
</h5>
<!-- Add special labels -->
<!-- {#if index === 0 && sortedPullOptions.length > 1}
<span
class="badge rounded bg-blue-500 px-2 py-1 text-xs text-white"
>
Larger
</span>
{:else if index === sortedPullOptions.length - 1 && sortedPullOptions.length > 1}
<span
class="badge rounded bg-green-500 px-2 py-1 text-xs text-white"
>
Smaller
</span>
{/if} -->
{#if pullOption.label.includes("Q4_K_M")}
<span
class="badge rounded bg-orange-500 px-2 py-1 text-xs text-white"
>
Recommended
</span>
{/if}
</div>
<!-- <div
class="text-surface-500 flex items-center gap-4 text-xs"
>
{#if pullOption.size}
<div
class="flex items-center gap-1"
>
<Icons.HardDrive size={12} />
<span>
{(
pullOption.size /
(1024 * 1024 * 1024)
).toFixed(2)} GB
</span>
</div>
{/if}
</div> -->
</div>
<button
class="btn btn-sm preset-filled-primary-500"
onclick={() => {
if (
selectedModelForDownload &&
pullOption.label
) {
onDownload(
selectedModelForDownload,
pullOption
)
}
}}
>
<Icons.Download size={14} />
Download
</button>
</div>
</div>
{/each}
</div>
{/if}
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-tonal" onclick={onClose}>Cancel</button>
</footer>
{/snippet}
</Modal>

View file

@ -1,41 +1,44 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean;
onOpenChange: (e: OpenChangeDetails) => void;
onConfirm: () => void;
onCancel: () => void;
}
import { Modal } from "@skeletonlabs/skeleton-svelte"
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props();
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: () => void
onCancel: () => void
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props()
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your lorebook has unsaved changes. Are you sure you want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>Cancel</button
>
<button class="btn preset-filled-error-500" onclick={onConfirm}>Discard</button
>
</footer>
{/snippet}
</Modal>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your lorebook has unsaved changes. Are you sure you want to
discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Discard
</button>
</footer>
{/snippet}
</Modal>

View file

@ -1,66 +1,137 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
import { z } from "zod"
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: (name: string) => void
onCancel: () => void
title?: string
description?: string
}
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: (name: string) => void
onCancel: () => void
title?: string
description?: string
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel,
title,
description
}: Props = $props()
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel,
title,
description
}: Props = $props()
let name = $state("")
let inputRef: HTMLInputElement | null = null
$effect(() => {
if (open && inputRef) inputRef.focus()
})
let isValid = $derived(!!name.trim())
// Zod validation schema
const nameSchema = z.object({
name: z.string().min(1, "Name is required").trim()
})
type ValidationErrors = Record<string, string>
let name = $state("")
let inputRef: HTMLInputElement | null = null
let validationErrors: ValidationErrors = $state({})
$effect(() => {
if (open && inputRef) inputRef.focus()
})
let isValid = $derived(
!!name.trim() && Object.keys(validationErrors).length === 0
)
function validateForm(): boolean {
const result = nameSchema.safeParse({ name })
if (result.success) {
validationErrors = {}
return true
} else {
const errors: ValidationErrors = {}
result.error.errors.forEach((error) => {
if (error.path.length > 0) {
errors[error.path[0] as string] = error.message
}
})
validationErrors = errors
return false
}
}
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">{title ? title : "Create new"}</h2>
</header>
<article>
{#if description}
<p class="text-muted-foreground mb-2">{description}</p>
{/if}
<input
bind:this={inputRef}
bind:value={name}
class="input w-full"
type="text"
placeholder="Enter a name..."
onkeydown={(e) => {
if (e.key === "Enter" && isValid) {
onConfirm(name)
}
}}
/>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>Cancel</button>
<button
class="btn preset-filled-primary-500"
onclick={() => onConfirm(name)}
disabled={!isValid}>Confirm</button
>
</footer>
{/snippet}
{#snippet content()}
<header class="flex justify-between">
<h2 id="modal-title" class="h2">{title ? title : "Create new"}</h2>
</header>
<article class="space-y-4">
{#if description}
<p id="modal-description" class="text-muted-foreground">{description}</p>
{/if}
<div class="form-field">
<label for="name-input" class="sr-only">
Name
</label>
<input
id="name-input"
bind:this={inputRef}
bind:value={name}
class="input w-full {validationErrors.name
? 'border-red-500'
: ''}"
type="text"
placeholder="Enter a name..."
aria-required="true"
aria-invalid={!!validationErrors.name}
aria-describedby={validationErrors.name ? "name-error" : undefined}
onkeydown={(e) => {
if (e.key === "Enter" && isValid) {
if (validateForm()) {
onConfirm(name)
}
}
}}
oninput={() => {
if (validationErrors.name) {
const { name, ...rest } = validationErrors
validationErrors = rest
}
}}
/>
{#if validationErrors.name}
<p id="name-error" class="mt-1 text-sm text-red-500" role="alert">
{validationErrors.name}
</p>
{/if}
</div>
</article>
<footer class="flex justify-end gap-4">
<button
class="btn preset-filled-surface-500"
onclick={onCancel}
type="button"
aria-label="Cancel and close modal"
>
Cancel
</button>
<button
class="btn preset-filled-primary-500"
onclick={() => {
if (validateForm()) {
onConfirm(name)
}
}}
disabled={!isValid}
type="button"
aria-label="Confirm and create new item"
>
Confirm
</button>
</footer>
{/snippet}
</Modal>

View file

@ -0,0 +1,279 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Modal, Progress } from "@skeletonlabs/skeleton-svelte"
interface DownloadProgress {
modelName: string
status: string
files: {
[key: string]: { total: number; completed: number }
}
}
interface Props {
open: boolean
downloadingQuants: {
[key: string]: {
modelName: string
status: string
files: {
[key: string]: { total: number; completed: number }
}
}
}
onCancel?: (modelName: string) => void
onClose?: () => void
}
let {
open = $bindable(),
downloadingQuants,
onCancel,
onClose
}: Props = $props()
// Check if model download is complete
function isComplete(progress: DownloadProgress) {
const files = Object.values(progress.files)
return (
files.length > 0 &&
files.every(
(file) => file.total > 0 && file.completed >= file.total
)
)
}
// Check if download is done (complete, canceled, or error)
function isDone(progress: DownloadProgress) {
const status = progress.status.toLowerCase()
return (
status === "canceled" ||
status === "success" ||
status === "error" ||
isComplete(progress)
)
}
// Check if all downloads are done
function areAllDownloadsDone() {
const downloads = Object.values(downloadingQuants)
return (
downloads.length > 0 &&
downloads.every((download) => isDone(download))
)
}
// Handle modal close
function handleClose() {
if (onClose) {
onClose()
}
open = false
}
// Get file count and completion status
function getFileStats(progress: DownloadProgress) {
const files = Object.values(progress.files)
const completedFiles = files.filter(
(file) => file.total > 0 && file.completed >= file.total
).length
const totalFiles = files.length
return { completed: completedFiles, total: totalFiles }
}
</script>
<Modal
{open}
onOpenChange={(e) => {
if (!e.open && areAllDownloadsDone()) {
handleClose()
}
}}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-2xl border border-surface-300-700 w-[40em] max-w-dvw-lg max-h-[90dvh]"
backdropClasses="backdrop-blur-md bg-black/20"
>
{#snippet content()}
<header class="border-surface-300-700 border-b pb-4">
<div class="flex items-center gap-3">
<div class="bg-primary-500/10 rounded-full p-2">
<Icons.Download size={20} class="text-primary-500" />
</div>
<div>
<h2 class="h3 font-bold">Model Downloads</h2>
<p class="text-surface-500 text-sm">
{Object.keys(downloadingQuants).length} model{Object.keys(
downloadingQuants
).length !== 1
? "s"
: ""} downloading
</p>
</div>
</div>
</header>
<article class="max-h-80 space-y-6 overflow-y-auto pr-2">
{#each Object.entries(downloadingQuants) as [key, progress]}
{#if key === "undefined"}
<!-- Skip undefined keys -->
{:else}
{@const fileStats = getFileStats(progress)}
<div
class="bg-surface-200-800 border-surface-300-700 rounded-lg border p-4"
>
<div class="flex items-start gap-4">
<div
class="bg-primary-500/10 mt-1 rounded-full p-2"
>
{#if isDone(progress)}
{#if progress.status.toLowerCase() === "canceled"}
<Icons.X
size={16}
class="text-orange-500"
/>
{:else if progress.status.toLowerCase() === "error"}
<Icons.AlertTriangle
size={16}
class="text-red-500"
/>
{:else}
<Icons.Check
size={16}
class="text-green-500"
/>
{/if}
{:else}
<Icons.Download
size={16}
class="text-primary-500 animate-pulse"
/>
{/if}
</div>
<div class="min-w-0 flex-1">
<div
class="mb-3 flex items-center justify-between"
>
<h4
class="text-surface-900-100 truncate font-semibold"
>
{progress.modelName}
</h4>
<div class="ml-2 flex items-center gap-2">
{#if onCancel && !isDone(progress)}
<button
class="btn btn-sm preset-filled-error-500"
onclick={() =>
onCancel?.(
progress.modelName
)}
title="Cancel download"
>
<Icons.X size={14} />
Cancel
</button>
{/if}
</div>
</div>
<div class="space-y-3">
<!-- Individual file progress bars -->
<div class="space-y-3">
{#each Object.entries(progress.files) as [fileName, fileProgress]}
<div class="space-y-1">
<div
class="flex items-center justify-between text-xs"
>
<span
class="text-surface-500 max-w-[60%] truncate font-mono"
title={fileName}
>
{fileName}
</span>
<span
class="text-surface-400 font-mono"
>
{fileProgress.total > 0
? `${((fileProgress.completed / fileProgress.total) * 100).toFixed(1)}%`
: "0%"}
</span>
</div>
<div class="w-full">
<Progress
value={fileProgress.completed}
max={fileProgress.total}
/>
</div>
{#if fileProgress.total > 0}
<div
class="text-surface-400 flex justify-end font-mono text-[10px]"
>
{(
fileProgress.completed /
(1024 * 1024)
).toFixed(1)}MB /
{(
fileProgress.total /
(1024 * 1024)
).toFixed(1)}MB
</div>
{/if}
</div>
{/each}
</div>
<div
class="border-surface-300-700 flex items-center justify-between border-t pt-2 text-xs"
>
<div class="flex items-center gap-2">
<div
class="h-2 w-2 rounded-full {isDone(
progress
)
? ''
: 'animate-pulse'} {progress.status.toLowerCase() ===
'canceled'
? 'bg-orange-500'
: progress.status.toLowerCase() ===
'error'
? 'bg-red-500'
: progress.status.toLowerCase() ===
'success' ||
isComplete(
progress
)
? 'bg-green-500'
: 'bg-blue-500'}"
></div>
<span
class="text-surface-600-400 font-medium"
>
{progress.status}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
{/each}
</article>
<footer class="border-surface-300-700 border-t pt-4">
<div
class="text-surface-500 flex items-center justify-between text-xs"
>
<!-- <div class="flex items-center gap-2">
<Icons.Info size={14} />
<span>Downloads will continue in the background</span>
</div> -->
{#if areAllDownloadsDone()}
<button
class="btn btn-sm preset-filled-primary-500"
onclick={handleClose}
>
<Icons.X size={14} />
Close
</button>
{/if}
</div>
</footer>
{/snippet}
</Modal>

View file

@ -0,0 +1,135 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean
modelName: string
onclose: () => void
onconfirm: (modelName: string) => void
}
let { open = $bindable(), modelName, onclose, onconfirm }: Props = $props()
let inputValue = $state(modelName)
let isLoading = $state(false)
// Function to clean the model name
function cleanModelName(input: string): string {
let cleaned = input.trim()
// Remove "ollama pull " prefix if present
if (cleaned.toLowerCase().startsWith("ollama pull ")) {
cleaned = cleaned.substring(12).trim()
}
// Remove "ollama run " prefix if present
if (cleaned.toLowerCase().startsWith("ollama run ")) {
cleaned = cleaned.substring(11).trim()
}
return cleaned
}
function handleConfirm() {
const cleanedName = cleanModelName(inputValue)
if (cleanedName) {
isLoading = true
onconfirm(cleanedName)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
handleConfirm()
}
}
// Reset input when modal opens
$effect(() => {
if (open) {
inputValue = modelName
isLoading = false
}
})
</script>
<Modal
{open}
onOpenChange={(e) => {
if (!e.open) {
onclose()
}
}}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl w-[30em] max-w-dvw-lg border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<div class="space-y-2">
<div class="mb-2 flex items-center gap-3">
<div class="bg-primary-500/10 rounded-full p-2">
<Icons.Download size={20} class="text-primary-500" />
</div>
<h3 class="text-foreground text-lg font-bold">Install Model</h3>
</div>
<p class="text-muted-foreground text-sm">
Enter the model name to download. You can use formats like
"ollama pull model", "ollama run model", or just "model".
</p>
</div>
<div class="space-y-3">
<div>
<label
class="text-foreground mb-1 block text-sm font-medium"
for="modelNameInput"
>
Model Name
</label>
<input
id="modelNameInput"
type="text"
bind:value={inputValue}
onkeydown={handleKeydown}
placeholder="e.g., llama2, ollama pull llama2, ollama run llama2"
class="input w-full"
disabled={isLoading}
/>
</div>
{#if inputValue.trim()}
<div class="bg-surface-100-900 rounded border p-3">
<div class="text-muted-foreground mb-1 text-xs font-medium">
Will install:
</div>
<div class="text-foreground font-mono text-sm">
{cleanModelName(inputValue)}
</div>
</div>
{/if}
</div>
<div class="flex gap-4">
<button
class="btn btn-secondary flex-1"
onclick={onclose}
disabled={isLoading}
>
Cancel
</button>
<button
class="btn btn-primary flex-1"
onclick={handleConfirm}
disabled={isLoading || !inputValue.trim()}
>
{#if isLoading}
<Icons.Loader2 size={14} class="animate-spin" />
Installing...
{:else}
<Icons.Download size={14} />
Install
{/if}
</button>
</div>
{/snippet}
</Modal>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean
modelName: string
onClose: () => void
onContinue: () => void
}
let { open = $bindable(), modelName, onClose, onContinue }: Props = $props()
// Generate the Ollama library URL for the model
let ollamaUrl = $derived(() => {
if (!modelName) return ""
// Extract the base model name (remove any version/tag info)
const baseModelName = modelName.split(":")[0]
return `https://ollama.com/library/${baseModelName}/tags`
})
</script>
<Modal
{open}
onOpenChange={(e) => (open = e.open)}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl w-[40em] max-w-dvw-lg border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex items-center justify-between">
<h2 class="h2">Select Model Version</h2>
<button onclick={onClose} class="btn-icon btn-icon-sm">
<Icons.X size={16} />
</button>
</header>
<article class="space-y-4">
<div
class="bg-primary-100 dark:bg-primary-900 border-primary-300 dark:border-primary-700 rounded-lg border p-4"
>
<div class="flex items-start gap-3">
<Icons.Info
size={20}
class="text-primary-600 mt-0.5 flex-shrink-0"
/>
<div class="flex-1">
<h3
class="text-primary-800 dark:text-primary-200 mb-2 font-semibold"
>
Choose Your Model Version
</h3>
<p class="text-primary-700-300 mb-3 text-sm">
To download <strong>{modelName}</strong>
, you'll need to select a specific version from the Ollama
library.
</p>
<div class="space-y-2 text-sm">
<p class="text-primary-700-300">
<strong>Step 1:</strong>
Click the button below to open the model's page on
Ollama.com
</p>
<p class="text-primary-700-300">
<strong>Step 2:</strong>
Browse the available versions and copy the full name
of the one you want
</p>
<p class="text-primary-700-300">
<strong>Step 3:</strong>
Come back here and paste the name in the next screen
</p>
<p class="text-primary-700-300">
<em>
(Q4_K_M is generally recommended for a
balance between performance and resource
usage)
</em>
</p>
</div>
<div class="mt-4">
<a
href={ollamaUrl()}
target="_blank"
rel="noopener noreferrer"
class="btn preset-filled-primary-500 inline-flex items-center gap-2"
>
<Icons.ExternalLink size={16} />
View {modelName} on Ollama.com
</a>
</div>
</div>
</div>
</div>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-tonal" onclick={onClose}>Cancel</button>
<button class="btn preset-filled-primary-500" onclick={onContinue}>
<Icons.ArrowRight size={16} />
Continue to Download
</button>
</footer>
{/snippet}
</Modal>

View file

@ -0,0 +1,146 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean
modelName: string
exampleModelName?: string
onclose: () => void
onconfirm: (modelName: string) => void
}
let {
open = $bindable(),
modelName,
exampleModelName = "llama3.1",
onclose,
onconfirm
}: Props = $props()
let inputValue = $state(modelName)
let isLoading = $state(false)
// Function to clean the model name
function cleanModelName(input: string): string {
let cleaned = input.trim()
// Remove "ollama pull " prefix if present
if (cleaned.toLowerCase().startsWith("ollama pull ")) {
cleaned = cleaned.substring(12).trim()
}
// Remove "ollama run " prefix if present
if (cleaned.toLowerCase().startsWith("ollama run ")) {
cleaned = cleaned.substring(11).trim()
}
return cleaned
}
function handleConfirm() {
const cleanedName = cleanModelName(inputValue)
if (cleanedName) {
isLoading = true
onconfirm(cleanedName)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
handleConfirm()
}
}
// Reset input when modal opens
$effect(() => {
if (open) {
inputValue = modelName
isLoading = false
}
})
</script>
<Modal
{open}
onOpenChange={(e) => {
if (!e.open) {
onclose()
}
}}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl w-[30em] max-w-dvw-lg border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<div class="space-y-2">
<div class="mb-2 flex items-center gap-3">
<h3 class="text-foreground text-lg font-bold">Install Model</h3>
</div>
<p class="text-muted-foreground text-sm">
Enter the model name to download. You can use formats like:
<br />
<code class="code">ollama pull {exampleModelName}</code>
<br />
<code class="code">ollama run {exampleModelName}</code>
<br />
or
<code class="code">{exampleModelName}</code>
</p>
</div>
<div class="space-y-3">
<div>
<label
class="text-foreground mb-1 block text-sm font-medium"
for="modelNameInput"
>
Model Name or Pull Command
</label>
<input
id="modelNameInput"
type="text"
bind:value={inputValue}
onkeydown={handleKeydown}
placeholder="{exampleModelName}, ollama pull {exampleModelName}"
class="input w-full"
disabled={isLoading}
/>
</div>
{#if inputValue.trim()}
<div class="bg-surface-100-900 rounded border p-3">
<div class="text-muted-foreground mb-1 text-xs font-medium">
Will install:
</div>
<div class="text-foreground font-mono text-sm">
{cleanModelName(inputValue)}
</div>
</div>
{/if}
</div>
<div class="flex gap-4">
<button
class="btn preset-filled-surface-500 flex-1"
onclick={onclose}
disabled={isLoading}
>
Cancel
</button>
<button
class="btn preset-filled-primary-500 flex-1"
onclick={handleConfirm}
disabled={isLoading || !inputValue.trim()}
>
{#if isLoading}
<Icons.Loader2 size={14} class="animate-spin" />
Installing...
{:else}
<Icons.Download size={14} />
Install
{/if}
</button>
</div>
{/snippet}
</Modal>

View file

@ -0,0 +1,655 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import * as skio from "sveltekit-io"
import { onDestroy, onMount } from "svelte"
import { z } from "zod"
import Avatar from "../Avatar.svelte"
interface Props {
open: boolean
onOpenChange?: (e: { open: boolean }) => void
}
let { open = $bindable(), onOpenChange }: Props = $props()
const socket = skio.get()
// Persona data interface
interface PersonaData {
name: string
description: string
avatar: string
isDefault: boolean
_avatarFile?: File | undefined
_avatar?: string
}
// Zod validation schema (only required fields for creation)
const personaSchema = z.object({
name: z.string().min(1, "Name is required").trim(),
description: z.string().min(1, "Description is required").trim(),
isDefault: z.boolean().optional()
})
type ValidationErrors = Record<string, string>
// State
let currentStep = $state(0)
let personaData: PersonaData = $state({
name: "",
description: "",
avatar: "",
isDefault: false,
_avatarFile: undefined,
_avatar: ""
})
let validationErrors: ValidationErrors = $state({})
let showCancelConfirmation = $state(false)
// Step definitions
const steps = [
{ title: "Name", canSkip: false },
{ title: "Avatar", canSkip: true },
{ title: "Description", canSkip: false }
]
// Validation functions
function validateCurrentStep(): boolean {
const step = steps[currentStep]
// Only validate required steps
if (!step.canSkip) {
if (currentStep === 0) {
// Step 1: Name is required
if (!personaData.name.trim()) {
validationErrors = { name: "Name is required" }
return false
}
} else if (currentStep === 2) {
// Step 3: Description is required
if (!personaData.description.trim()) {
validationErrors = {
description: "Description is required"
}
return false
}
}
}
validationErrors = {}
return true
}
function validateFinalForm(): boolean {
const result = personaSchema.safeParse(personaData)
if (result.success) {
validationErrors = {}
return true
} else {
const errors: ValidationErrors = {}
result.error.errors.forEach((error) => {
if (error.path.length > 0) {
errors[error.path[0] as string] = error.message
}
})
validationErrors = errors
return false
}
}
// Avatar handling
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement | null
if (!input || !input.files || input.files.length === 0) return
const file = input.files[0]
if (!file) return
// Set preview
const previewReader = new FileReader()
previewReader.onload = (ev2) => {
personaData._avatar = ev2.target?.result as string
}
previewReader.readAsDataURL(file)
// Store file for later upload
personaData._avatarFile = file
}
// Navigation functions
function handleNext() {
// Validate current step if it's required
if (!steps[currentStep].canSkip && !validateCurrentStep()) {
return
}
if (currentStep < steps.length - 1) {
currentStep++
}
}
function handlePrevious() {
if (currentStep > 0) {
currentStep--
}
}
function handleSave() {
if (!validateFinalForm()) {
// Find the first step with validation errors and go to it
if (validationErrors.name) {
currentStep = 0
} else if (validationErrors.description) {
currentStep = 2
}
return
}
// Prepare persona data for creation
const newPersona = {
...personaData,
position: 0
}
const avatarFile = newPersona._avatarFile
delete newPersona._avatarFile
delete newPersona._avatar
socket.emit("createPersona", {
persona: newPersona,
avatarFile
})
}
function resetForm() {
// Reset form data
personaData = {
name: "",
description: "",
avatar: "",
isDefault: false,
_avatarFile: undefined,
_avatar: ""
}
validationErrors = {}
currentStep = 0
open = false
}
function handleCancel() {
if (hasUnsavedData) {
showCancelConfirmation = true
} else {
resetForm()
}
}
function handleCancelConfirm() {
showCancelConfirmation = false
resetForm()
}
function handleCancelCancel() {
showCancelConfirmation = false
}
function clearValidationError(field: string) {
if (validationErrors[field]) {
const { [field]: removed, ...rest } = validationErrors
validationErrors = rest
}
}
// Computed properties
let isLastStep = $derived(currentStep === steps.length - 1)
let isFirstStep = $derived(currentStep === 0)
let canProceedToNext = $derived(() => {
// Always allow proceeding on optional steps
if (steps[currentStep].canSkip) return true
// For required steps, validate the current step
return validateCurrentStep()
})
// Check if any fields are populated (has unsaved data)
let hasUnsavedData = $derived(
personaData.name.trim() !== "" ||
personaData.description.trim() !== "" ||
!!personaData._avatarFile
)
onMount(() => {
socket.on("createPersona", (res: any) => {
if (res.persona) {
resetForm() // This will close the modal and reset data
}
})
})
onDestroy(() => {
socket.off("createPersona")
})
</script>
<Modal
{open}
onOpenChange={(e) => {
if (!e.open && hasUnsavedData && !showCancelConfirmation) {
// If trying to close and has unsaved data, show confirmation
showCancelConfirmation = true
return
}
// Otherwise allow normal close behavior
onOpenChange?.(e)
}}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
{#if showCancelConfirmation}
<!-- Cancel Confirmation View -->
<header class="flex items-center justify-between">
<h2 class="h2">Confirm Action</h2>
<button
class="btn btn-sm preset-tonal-surface"
onclick={handleCancelCancel}
aria-label="Go back to editing"
>
<Icons.X size={16} />
</button>
</header>
<article class="flex min-h-[200px] items-center justify-center">
<div class="space-y-4 text-center">
<div class="text-warning-500 mb-4">
<Icons.AlertTriangle size={48} class="mx-auto" />
</div>
<h3 class="h3">Discard Persona?</h3>
<p class="max-w-md text-sm opacity-75">
You have unsaved changes to your persona. Are you sure
you want to discard them and close the creator?
</p>
</div>
</article>
<footer class="flex justify-end gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handleCancelCancel}
>
<Icons.ArrowLeft size={16} />
Keep Editing
</button>
<button
class="btn preset-filled-error-500"
onclick={handleCancelConfirm}
>
<Icons.Trash2 size={16} />
Discard Changes
</button>
</footer>
{:else}
<!-- Normal Form View -->
<header class="flex items-center justify-between">
<div>
<h2 class="h2">Create Persona</h2>
<p class="text-sm opacity-60">
Step {currentStep + 1} of {steps.length}: {steps[
currentStep
].title}
</p>
</div>
<button
class="btn btn-sm preset-tonal-surface"
onclick={handleCancel}
aria-label="Close persona creator"
>
<Icons.X size={16} />
</button>
</header>
<!-- Progress indicator -->
<div class="flex gap-2">
{#each steps as _, index}
<div
class="h-2 flex-1 rounded-full {index <= currentStep
? 'bg-primary-500'
: 'bg-surface-400'}"
></div>
{/each}
</div>
<!-- Step content -->
<article class="min-h-[400px]">
{#if currentStep === 0}
<!-- Step 1: Name -->
<div class="space-y-6">
<div class="space-y-2 text-center">
<h3 class="h3">What's your persona's name?</h3>
<p class="text-sm opacity-75">
This represents you in conversations. You can
create multiple personas for different contexts.
</p>
</div>
<div class="space-y-4">
<!-- Name Field -->
<div class="space-y-2">
<label
class="flex gap-1 font-semibold"
for="stepName"
>
Name*
<span
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
title="This field will be visible in prompts"
aria-label="This field will be visible in prompts"
>
<Icons.ScanEye
size={16}
class="relative top-[1px] inline"
aria-hidden="true"
/>
</span>
</label>
<input
id="stepName"
type="text"
bind:value={personaData.name}
class="input {validationErrors.name
? 'border-red-500 focus:border-red-500'
: ''}"
placeholder="Enter your persona name..."
aria-required="true"
aria-invalid={validationErrors.name
? "true"
: "false"}
aria-describedby={validationErrors.name
? "name-error"
: undefined}
oninput={() => clearValidationError("name")}
/>
{#if validationErrors.name}
<p
class="mt-1 text-sm text-red-500"
id="name-error"
role="alert"
>
{validationErrors.name}
</p>
{/if}
</div>
</div>
<!-- Example -->
<div class="bg-primary-500/10 rounded-lg p-4">
<h4
class="mb-3 flex items-center gap-2 text-sm font-semibold"
>
<Icons.Sparkles
size={16}
class="text-primary-500"
/>
Example & Guidelines
</h4>
<div class="space-y-3">
<div class="space-y-1 text-sm opacity-75">
<p>
<strong>Examples:</strong>
"Alex", "Dr. Smith", "The Investigator"
</p>
</div>
<div
class="border-primary-500/20 space-y-2 border-t pt-3 text-xs opacity-60"
>
<p>
<strong>Name:</strong>
Choose something that represents how you
want to be addressed in conversations. This
can be your real name, a nickname, or a role-based
identity.
</p>
</div>
</div>
</div>
</div>
{:else if currentStep === 1}
<!-- Step 2: Avatar -->
<div class="space-y-6">
<div class="space-y-2 text-center">
<h3 class="h3">Add an avatar</h3>
<p class="text-sm opacity-75">
Upload an image to represent yourself. This step
is optional but helps personalize your persona.
</p>
</div>
<div class="flex items-center gap-6">
<!-- Avatar Preview -->
<div class="flex-shrink-0">
<Avatar
src={personaData._avatar ||
personaData.avatar}
char={personaData}
/>
</div>
<!-- Upload Area -->
<div class="flex-1 space-y-3">
<div
class="flex w-full items-center justify-center"
>
<label
for="avatar-upload"
class="flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-800"
>
<div
class="flex flex-col items-center justify-center"
>
<Icons.Upload
class="mb-3 h-8 w-8 text-gray-500 dark:text-gray-400"
/>
<p
class="mb-2 text-sm text-gray-500 dark:text-gray-400"
>
<span class="font-semibold">
Click to upload
</span>
or drag and drop
</p>
<p
class="text-xs text-gray-500 dark:text-gray-400"
>
PNG, JPG or GIF
</p>
</div>
<input
id="avatar-upload"
type="file"
class="hidden"
accept="image/*"
onchange={handleAvatarChange}
/>
</label>
</div>
{#if personaData._avatarFile}
<button
type="button"
class="btn btn-sm preset-tonal-error w-full"
onclick={() => {
personaData._avatarFile = undefined
personaData._avatar = ""
}}
>
<Icons.Trash2 size={16} />
Remove Image
</button>
{/if}
<p class="text-xs opacity-60">
Supported formats: JPG, PNG, GIF. The image
will be resized automatically to fit the
interface.
</p>
</div>
</div>
<!-- Example -->
<div class="bg-primary-500/10 rounded-lg p-4">
<h4
class="mb-2 flex items-center gap-2 text-sm font-semibold"
>
<Icons.Sparkles
size={16}
class="text-primary-500"
/>
Tip
</h4>
<p class="text-sm opacity-75">
A good avatar helps distinguish your different
personas and makes conversations more engaging.
You can always change it later.
</p>
</div>
</div>
{:else if currentStep === 2}
<!-- Step 3: Description -->
<div class="space-y-6">
<div class="space-y-2 text-center">
<h3 class="h3">Describe yourself</h3>
<p class="text-sm opacity-75">
Write a description that captures your
background, personality, and how you want to be
perceived in conversations.
</p>
</div>
<div class="space-y-2">
<label
class="flex gap-1 font-semibold"
for="stepDescription"
>
Description*
<span
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
title="This field will be visible in prompts"
aria-label="This field will be visible in prompts"
>
<Icons.ScanEye
size={16}
class="relative top-[1px] inline"
aria-hidden="true"
/>
</span>
</label>
<textarea
id="stepDescription"
rows="8"
bind:value={personaData.description}
class="input {validationErrors.description
? 'border-red-500 focus:border-red-500'
: ''}"
placeholder="Describe yourself and how you want to interact..."
aria-required="true"
aria-invalid={validationErrors.description
? "true"
: "false"}
aria-describedby={validationErrors.description
? "description-error"
: undefined}
oninput={() =>
clearValidationError("description")}
></textarea>
{#if validationErrors.description}
<p
class="mt-1 text-sm text-red-500"
id="description-error"
role="alert"
>
{validationErrors.description}
</p>
{/if}
<div class="space-y-2 text-xs opacity-60">
<p>
<strong>Include:</strong>
Your background, interests, communication style,
or the role you want to play
</p>
<p>
<strong>Examples:</strong>
"A curious student", "An experienced professional",
"Someone who loves asking questions"
</p>
</div>
</div>
<!-- Example -->
<div class="bg-primary-500/10 rounded-lg p-4">
<h4
class="mb-2 flex items-center gap-2 text-sm font-semibold"
>
<Icons.Sparkles
size={16}
class="text-primary-500"
/>
Example
</h4>
<p class="text-sm opacity-75">
"An inquisitive person who enjoys deep
conversations about philosophy and science. He
asks thoughtful questions and share insights
from your background in education. He is an
empathetic, and genuinely interested in learning
from others."
</p>
</div>
</div>
{/if}
</article>
<!-- Navigation -->
<footer class="flex justify-between gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handlePrevious}
disabled={isFirstStep}
>
<Icons.ChevronLeft size={16} />
Previous
</button>
<div class="flex gap-2">
{#if steps[currentStep].canSkip && !isLastStep}
<button
class="btn preset-tonal-surface"
onclick={handleNext}
>
Skip
<Icons.ChevronRight size={16} />
</button>
{/if}
{#if isLastStep}
<button
class="btn preset-filled-success-500"
onclick={handleSave}
>
<Icons.Save size={16} />
Create Persona
</button>
{:else}
<button
class="btn preset-filled-primary-500"
onclick={handleNext}
disabled={!canProceedToNext}
>
Next
<Icons.ChevronRight size={16} />
</button>
{/if}
</div>
</footer>
{/if}
{/snippet}
</Modal>

View file

@ -32,11 +32,11 @@
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-h-[95dvh] relative overflow-hidden w-[50em] max-w-95dvw"
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-h-[95dvh] relative overflow-hidden w-[50em] max-w-95dvw"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="mb-2 flex items-center justify-between">
<header class="flex items-center justify-between">
<h2 class="h2">Select Persona</h2>
<button
class="btn btn-sm"
@ -46,7 +46,7 @@
</button>
</header>
<input
class="input mb-4 w-full"
class="input w-full"
type="text"
placeholder="Search personas..."
bind:value={search}

View file

@ -1,43 +1,44 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean;
onOpenChange: (e: OpenChangeDetails) => void;
onConfirm: () => void;
onCancel: () => void;
}
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: () => void
onCancel: () => void
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props();
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props()
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your persona has unsaved changes. Are you sure you want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Discard
</button>
</footer>
{/snippet}
</Modal>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your persona has unsaved changes. Are you sure you want to
discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Discard
</button>
</footer>
{/snippet}
</Modal>

View file

@ -1,41 +1,44 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean;
onOpenChange: (e: OpenChangeDetails) => void;
onConfirm: () => void;
onCancel: () => void;
}
import { Modal } from "@skeletonlabs/skeleton-svelte"
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props();
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: () => void
onCancel: () => void
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props()
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your prompt configuration has unsaved changes. Are you sure you want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>Cancel</button
>
<button class="btn preset-filled-error-500" onclick={onConfirm}>Discard</button
>
</footer>
{/snippet}
</Modal>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your prompt configuration has unsaved changes. Are you sure you
want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Discard
</button>
</footer>
{/snippet}
</Modal>

View file

@ -1,47 +1,51 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean;
onOpenChange: (e: { open: boolean }) => void;
onConfirm: () => void;
onCancel: () => void;
name?: string;
type?: 'character' | 'persona';
}
interface Props {
open: boolean
onOpenChange: (e: { open: boolean }) => void
onConfirm: () => void
onCancel: () => void
name?: string
type?: "character" | "persona"
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel,
name = '',
type = 'character',
}: Props = $props();
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel,
name = "",
type = "character"
}: Props = $props()
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Remove {type === 'persona' ? 'Persona' : 'Character'}?</h2>
</header>
<article>
<p class="opacity-60">
Are you sure you want to remove {type === 'persona' ? 'this persona' : 'this character'}{name ? ` (${name})` : ''} from the chat?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Remove
</button>
</footer>
{/snippet}
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">
Remove {type === "persona" ? "Persona" : "Character"}?
</h2>
</header>
<article>
<p class="opacity-60">
Are you sure you want to remove {type === "persona"
? "this persona"
: "this character"}{name ? ` (${name})` : ""} from the chat?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Remove
</button>
</footer>
{/snippet}
</Modal>

View file

@ -1,41 +1,44 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean;
onOpenChange: (e: OpenChangeDetails) => void;
onConfirm: () => void;
onCancel: () => void;
}
import { Modal } from "@skeletonlabs/skeleton-svelte"
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props();
interface Props {
open: boolean
onOpenChange: (e: OpenChangeDetails) => void
onConfirm: () => void
onCancel: () => void
}
let {
open = $bindable(),
onOpenChange,
onConfirm,
onCancel
}: Props = $props()
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-md"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your sampling have unsaved changes. Are you sure you want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>Cancel</button
>
<button class="btn preset-filled-error-500" onclick={onConfirm}>Discard</button
>
</footer>
{/snippet}
</Modal>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your sampling configuration has unsaved changes. Are you sure
you want to discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-filled-surface-500" onclick={onCancel}>
Cancel
</button>
<button class="btn preset-filled-error-500" onclick={onConfirm}>
Discard
</button>
</footer>
{/snippet}
</Modal>

View file

@ -0,0 +1,579 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import * as skio from "sveltekit-io"
import { onMount, onDestroy } from "svelte"
import { toaster } from "$lib/client/utils/toaster"
import { OllamaModelSearchSource } from "$lib/shared/constants/OllamaModelSource"
import HuggingFaceQuantizationModal from "$lib/client/components/modals/HuggingFaceQuantizationModal.svelte"
import OllamaManualPullModal from "$lib/client/components/modals/OllamaManualPullModal.svelte"
import OllamaInstructionModal from "$lib/client/components/modals/OllamaInstructionModal.svelte"
interface OllamaModel {
name: string
size: number
digest: string
modified_at: string
details?: {
parameter_size: string
quantization_level: string
}
}
interface Props {
// Callback when a download starts - to switch tabs
onDownloadStart?: (modelName: string) => void
}
let { onDownloadStart }: Props = $props()
const socket = skio.get()
let searchString = $state("")
let installedModels: Sockets.OllamaModelsList.Response["models"] = $state(
[]
)
let selectedSource = $state(OllamaModelSearchSource.RECOMMENDED)
let availableModels: Sockets.OllamaSearchAvailableModels.Response["models"] =
$state([])
let recommendedModels: Sockets.OllamaRecommendedModels.Response["models"] =
$state([])
let isSearching = $state(false)
let showHuggingFaceModal = $state(false)
let showOllamaInstructionModal = $state(false)
let showOllamaManualPullModal = $state(false)
let selectedModelForDownload: string | null = $state(null)
let selectedModel:
| Sockets.OllamaSearchAvailableModels.Response["models"][0]
| null = $state(null)
// Track which models are being downloaded locally (for UI state only)
let currentlyDownloading = $state(new Set<string>())
function isModelInstalled(modelName: string): boolean {
if (selectedSource === OllamaModelSearchSource.RECOMMENDED) {
// For recommended models, check against the pull string
const modelNameFromPull =
modelName.split("/").pop()?.split(":")[0] || modelName
return installedModels.some(
(model) =>
model.name.includes(modelNameFromPull) ||
model.name.startsWith(modelName.replace("hf.co/", ""))
)
}
return installedModels.some((model) => model.name.startsWith(modelName))
}
function searchAvailableModels() {
isSearching = true
if (selectedSource === OllamaModelSearchSource.RECOMMENDED) {
socket.emit("ollamaRecommendedModels", {})
} else {
socket.emit("ollamaSearchAvailableModels", {
search: searchString.trim(),
source: selectedSource
} as Sockets.OllamaSearchAvailableModels.Call)
}
}
function openHuggingFaceModal(
model: Sockets.OllamaSearchAvailableModels.Response["models"][0]
) {
selectedModelForDownload = model.name
selectedModel = model
showHuggingFaceModal = true
}
function closeHuggingFaceModal() {
showHuggingFaceModal = false
selectedModelForDownload = null
selectedModel = null
}
function downloadHuggingFaceQuantization(
modelId: string,
pullOption: { label: string; pull: string }
) {
console.log("Downloading Hugging Face quantization:", pullOption.pull)
// Track this model as currently downloading
currentlyDownloading.add(modelId)
// Emit the pull request to Ollama
socket.emit("ollamaPullModel", {
modelName: pullOption.pull
} as Sockets.OllamaPullModel.Call)
// Close modal and switch to downloads tab
closeHuggingFaceModal()
onDownloadStart?.(pullOption.pull)
}
function openOllamaManualPullModal(modelName: string) {
selectedModelForDownload = modelName
showOllamaManualPullModal = true
}
function openOllamaInstructionModal(modelName: string) {
selectedModelForDownload = modelName
showOllamaInstructionModal = true
}
function closeOllamaInstructionModal() {
showOllamaInstructionModal = false
selectedModelForDownload = null
}
function handleInstructionContinue() {
showOllamaInstructionModal = false
showOllamaManualPullModal = true
}
function closeOllamaManualPullModal() {
showOllamaManualPullModal = false
selectedModelForDownload = null
}
function handleOllamaInstallConfirm(cleanedModelName: string) {
console.log("Installing Ollama model:", cleanedModelName)
// Track this model as currently downloading
currentlyDownloading.add(cleanedModelName)
// Emit the pull request to Ollama
socket.emit("ollamaPullModel", {
modelName: cleanedModelName
} as Sockets.OllamaPullModel.Call)
// Close modal and switch to downloads tab
closeOllamaManualPullModal()
onDownloadStart?.(cleanedModelName)
}
$effect(() => {
const _search = searchString.trim()
const _source = selectedSource
const timeoutId = setTimeout(() => {
searchAvailableModels()
}, 500) // 500ms delay
return () => clearTimeout(timeoutId)
})
async function refreshInstalled() {
socket.emit("ollamaModelsList", {})
}
onMount(() => {
// Socket event listeners
socket.on(
"ollamaModelsList",
(message: Sockets.OllamaModelsList.Response) => {
installedModels = message.models
}
)
socket.on(
"ollamaSearchAvailableModels",
(message: Sockets.OllamaSearchAvailableModels.Response) => {
isSearching = false
if (message.error) {
toaster.error({ title: message.error })
availableModels = []
} else {
availableModels = message.models || []
}
}
)
socket.on(
"ollamaRecommendedModels",
(message: Sockets.OllamaRecommendedModels.Response) => {
isSearching = false
if (message.error) {
toaster.error({ title: message.error })
recommendedModels = []
} else {
recommendedModels = message.models || []
}
}
)
socket.on(
"ollamaPullModel",
(message: Sockets.OllamaPullModel.Response) => {
// Handle model pull completion/error only - no progress handling
console.log("Pull model response:", message)
if (message.success) {
socket.emit("ollamaModelsList", {})
toaster.success({ title: "Model downloaded successfully" })
closeHuggingFaceModal()
} else if (message.error) {
toaster.error({ title: message.error })
}
}
)
// Load initial installed models
refreshInstalled()
})
onDestroy(() => {
socket.off("ollamaModelsList")
socket.off("ollamaSearchAvailableModels")
socket.off("ollamaRecommendedModels")
socket.off("ollamaPullModel")
socket.off("ollamaCancelPull")
})
</script>
<!-- Search for available models -->
<div class="flex flex-col gap-2 px-4 py-2">
<div class="flex gap-2">
<button
class="btn preset-filled-primary-500 flex-1"
onclick={() => {
showOllamaManualPullModal = true
}}
aria-label="Open manual download modal"
>
<Icons.Download size={16} />
Manual Download
</button>
<select
id="source"
name="source"
aria-label="Model search source"
class="select bg-background border-muted w-fit rounded border"
bind:value={selectedSource}
>
{#each OllamaModelSearchSource.options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="relative flex-1">
<Icons.Search
class="text-surface-500 absolute top-1/2 left-3 -translate-y-1/2 transform"
size={16}
/>
<input
type="text"
placeholder={selectedSource === OllamaModelSearchSource.RECOMMENDED
? "Search not available for recommended models"
: "Search available models..."}
class="input w-full pl-10"
aria-label="Search available models by name or description"
bind:value={searchString}
disabled={selectedSource === OllamaModelSearchSource.RECOMMENDED}
/>
</div>
</div>
<div class="space-y-3 p-4">
{#if isSearching}
<div class="p-6 text-center">
<Icons.Loader2 class="mx-auto mb-4 animate-spin" size={32} />
<p class="text-sm opacity-75">Searching for models...</p>
</div>
{:else if selectedSource === OllamaModelSearchSource.RECOMMENDED ? recommendedModels.length === 0 : availableModels.length === 0}
<div class="p-6 text-center">
<Icons.Search class="text-surface-500 mx-auto mb-4" size={48} />
<h3 class="h4 mb-2">No models found</h3>
<p class="mb-4 text-sm opacity-75">
{selectedSource === OllamaModelSearchSource.RECOMMENDED
? "No recommended models available."
: `No available models match your search for "${searchString}".`}
</p>
</div>
{:else if selectedSource === OllamaModelSearchSource.RECOMMENDED}
{#each recommendedModels as model}
<div class="card preset-tonal p-4">
<div class="flex flex-col gap-3">
<!-- Header with name and VRAM tier -->
<div class="flex items-start justify-between">
<div class="flex-1">
<h4
class="text-foreground mb-1 text-lg font-semibold"
>
{model.name}
</h4>
<div class="mb-2 flex flex-wrap items-center gap-2">
<span
class="badge preset-filled-primary-500 rounded-full px-2 py-1 text-xs"
>
{model.details.parameter_size}
</span>
<span
class="badge preset-filled-secondary-500 rounded-full px-2 py-1 text-xs"
>
{model.details.quantization_level}
</span>
<span
class="badge {model.recommended_vram <= 3
? 'text-green-500'
: model.recommended_vram <= 6
? 'text-blue-500'
: model.recommended_vram <= 10
? 'text-yellow-500'
: model.recommended_vram <= 16
? 'text-orange-500'
: 'text-red-500'} bg-surface-200 dark:bg-surface-800 rounded-full px-2 py-1 text-xs"
>
{model.recommended_vram}GB VRAM • {model.recommended_vram <=
3
? "Ultra Budget"
: model.recommended_vram <= 6
? "Budget"
: model.recommended_vram <= 10
? "Mainstream"
: model.recommended_vram <= 16
? "High-End"
: "Enthusiast"}
</span>
</div>
</div>
</div>
<!-- Description -->
<p class="text-muted-foreground text-sm leading-relaxed">
{model.details.description}
</p>
<!-- Metadata row -->
<div
class="text-surface-500 flex flex-wrap items-center gap-4 text-xs"
>
<div class="flex items-center gap-1">
<Icons.HardDrive size={12} />
<span>{model.size}GB</span>
</div>
{#if model.details.modified_at}
<div class="flex items-center gap-1">
<Icons.Calendar size={12} />
<span>Updated {model.details.modified_at}</span>
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
class="btn btn-sm {isModelInstalled(model.pull)
? 'preset-filled-success-500'
: 'preset-filled-primary-500'}"
onclick={() => {
console.log(
"Downloading recommended model:",
model.pull
)
currentlyDownloading.add(model.pull)
socket.emit("ollamaPullModel", {
modelName: model.pull
} as Sockets.OllamaPullModel.Call)
onDownloadStart?.(model.pull)
}}
disabled={isModelInstalled(model.pull)}
aria-label={isModelInstalled(model.pull)
? `Model ${model.name} is already installed`
: `Install model ${model.name}`}
>
{#if isModelInstalled(model.pull)}
<Icons.Check size={14} aria-hidden="true" />
Installed
{:else}
<Icons.Download size={14} aria-hidden="true" />
Install
{/if}
</button>
<a
href={`https://hf.co/${model.name}`}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-filled-secondary-500 text-center"
aria-label={`View ${model.name} model page in new tab`}
>
<Icons.ExternalLink size={14} aria-hidden="true" />
View
</a>
</div>
</div>
</div>
{/each}
{:else}
{#each availableModels as model}
<div class="card preset-tonal p-4">
<div class="flex flex-col gap-2">
<!-- Header with name and badges -->
<div class="flex items-start justify-between">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-lg font-semibold">
{model.name}
</h4>
</div>
</div>
<div>
{#if model.popular}
<span
class="badge preset-filled-tertiary-500 text-x rounded-full px-2 py-1"
role="img"
aria-label="Popular model"
>
<Icons.TrendingUp
size={12}
class="mr-1 inline"
aria-hidden="true"
/>
Popular
</span>
{/if}
{#if model.trendingScore && model.trendingScore > 0.7}
<span
class="badge preset-filled-secondary-500 rounded-full px-2 py-1 text-xs"
role="img"
aria-label="Trending model"
>
<Icons.Flame
size={12}
class="mr-1 inline"
aria-hidden="true"
/>
Trending
</span>
{/if}
</div>
<!-- Description -->
<p class="text-surface-500 mb-3 line-clamp-2 text-sm">
{model.description || "No description available"}
</p>
<!-- Tags -->
{#if model.tags && model.tags.length > 0}
<div class="mb-3 flex flex-wrap gap-1">
{#each model.tags.slice(0, 4) as tag}
<span
class="badge bg-surface-200-800 text-surface-700-300 rounded px-2 py-1 text-xs"
>
{tag}
</span>
{/each}
{#if model.tags.length > 4}
<span class="text-surface-500 text-xs">
+{model.tags.length - 4} more
</span>
{/if}
</div>
{/if}
<!-- Metadata row -->
<div
class="text-surface-500 flex flex-wrap items-center gap-4 text-xs"
>
{#if model.size}
<div class="flex items-center gap-1">
<Icons.HardDrive size={12} />
<span>{model.size}</span>
</div>
{/if}
{#if model.downloads}
<div class="flex items-center gap-1">
<Icons.Download size={12} />
<span>
{model.downloads.toLocaleString()} downloads
</span>
</div>
{/if}
{#if model.likes}
<div class="flex items-center gap-1">
<Icons.Heart size={12} />
<span>
{model.likes.toLocaleString()} likes
</span>
</div>
{/if}
{#if model.updatedAtStr}
<div class="flex items-center gap-1">
<Icons.Clock size={12} />
<span>Updated {model.updatedAtStr}</span>
</div>
{/if}
</div>
<div class="flex min-w-[100px] gap-2">
<button
class="btn btn-sm {isModelInstalled(model.name)
? 'preset-filled-success-500'
: 'preset-filled-primary-500'}"
onclick={() => {
if (
selectedSource ===
OllamaModelSearchSource.HUGGING_FACE
) {
openHuggingFaceModal(model)
} else if (
selectedSource ===
OllamaModelSearchSource.OLLAMA_DB
) {
openOllamaInstructionModal(model.name)
} else {
openOllamaManualPullModal(model.name)
}
}}
aria-label={isModelInstalled(model.name)
? `Model ${model.name} is already installed`
: `Install model ${model.name}`}
>
{#if isModelInstalled(model.name)}
<Icons.Check size={14} aria-hidden="true" />
Installed
{:else}
<Icons.Download size={14} aria-hidden="true" />
Install
{/if}
</button>
{#if model.url}
<a
href={model.url}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-filled-secondary-500 text-center"
aria-label={`View ${model.name} model page in new tab`}
>
<Icons.ExternalLink
size={14}
aria-hidden="true"
/>
View
</a>
{/if}
</div>
</div>
</div>
{/each}
{/if}
</div>
<!-- Hugging Face Download Modal -->
<HuggingFaceQuantizationModal
bind:open={showHuggingFaceModal}
{selectedModelForDownload}
{selectedModel}
onClose={closeHuggingFaceModal}
onDownload={downloadHuggingFaceQuantization}
/>
<!-- Ollama Install Modal -->
<OllamaManualPullModal
open={showOllamaManualPullModal}
modelName={selectedModelForDownload || ""}
onclose={closeOllamaManualPullModal}
onconfirm={handleOllamaInstallConfirm}
/>
<!-- Ollama Instruction Modal -->
<OllamaInstructionModal
bind:open={showOllamaInstructionModal}
modelName={selectedModelForDownload || ""}
onClose={closeOllamaInstructionModal}
onContinue={handleInstructionContinue}
/>

View file

@ -0,0 +1,319 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Progress } from "@skeletonlabs/skeleton-svelte"
import { onDestroy, onMount } from "svelte"
import * as skio from "sveltekit-io"
interface DownloadProgress {
modelName: string
status: string
files: {
[key: string]: { total: number; completed: number }
}
}
const socket = skio.get()
// Download progress state managed by this component
let downloadingQuants: {
[key: string]: {
modelName: string
status: string
isDone: boolean
files: {
[key: string]: { total: number; completed: number }
}
}
} = $state({})
let downloadingCount = $derived(
Object.keys(downloadingQuants).filter(
(key) => key !== "undefined" && !downloadingQuants[key].isDone
).length
)
let doneCount = $derived(
Object.keys(downloadingQuants).filter(
(key) => key !== "undefined" && downloadingQuants[key].isDone
).length
)
function isComplete(progress: DownloadProgress) {
const files = Object.values(progress.files)
return (
files.length > 0 &&
files.every(
(file) => file.total > 0 && file.completed >= file.total
)
)
}
// Get file count and completion status
function getFileStats(progress: DownloadProgress) {
const files = Object.values(progress.files)
const completedFiles = files.filter(
(file) => file.total > 0 && file.completed >= file.total
).length
const totalFiles = files.length
return { completed: completedFiles, total: totalFiles }
}
function cancelDownload(modelName: string) {
socket.emit("ollamaCancelPull", {
modelName
} as Sockets.OllamaCancelPull.Call)
}
function clearDownloadHistory() {
socket.emit(
"ollamaClearDownloadHistory",
{} as Sockets.OllamaClearDownloadHistory.Call
)
}
onMount(() => {
socket.on(
"ollamaPullProgress",
(message: Sockets.OllamaPullProgress.Response) => {
// Server sends the entire downloadingQuants object
downloadingQuants = message.downloadingQuants || {}
}
)
socket.on(
"ollamaClearDownloadHistory",
(message: Sockets.OllamaClearDownloadHistory.Response) => {
if (message.success) {
downloadingQuants = {}
}
}
)
// Request current download progress from server after setting up listeners
socket.emit("ollamaGetDownloadProgress", {})
})
onDestroy(() => {
socket.off("ollamaPullProgress")
socket.off("ollamaClearDownloadHistory")
})
</script>
<div class="flex h-full flex-col">
<div class="p-4">
{#if !downloadingCount && !doneCount}
<!-- No downloads state -->
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<Icons.Download
class="text-muted-foreground mx-auto mb-4 h-12 w-12"
/>
<h3 class="text-foreground mb-2 text-lg font-semibold">
No Active Downloads
</h3>
<p class="text-muted-foreground text-sm">
Model downloads will appear here when started from the
Available tab.
</p>
</div>
</div>
{/if}
{#if downloadingCount}
<!-- Downloads header -->
<div class="mb-6 flex flex-col gap-2">
<div>
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<div>
<h3 class="text-foreground text-lg font-bold">
Active Downloads
</h3>
<p class="text-muted-foreground text-sm">
{downloadingCount} model{downloadingCount !==
1
? "s"
: ""} downloading
</p>
</div>
</div>
</div>
</div>
<!-- Downloads list -->
<div class="space-y-4">
{#each Object.entries(downloadingQuants).filter(([key, progress]) => !progress.isDone) as [key, progress]}
{@render downloadItem(key, progress)}
{/each}
</div>
</div>
{/if}
{#if doneCount}
<!-- Downloads header -->
<div class="flex flex-col gap-2">
<div>
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<div>
<h3 class="text-foreground text-lg font-bold">
Completed Downloads
</h3>
<p class="text-muted-foreground text-sm">
{doneCount} model{doneCount !== 1
? "s"
: ""} completed
</p>
</div>
</div>
<!-- Clear history button -->
{#if Object.values(downloadingQuants).some((p) => p.isDone)}
<button
class="btn btn-sm preset-filled-surface-500"
onclick={clearDownloadHistory}
title="Clear completed downloads"
aria-label="Clear completed download history"
>
<Icons.Trash2 size={14} aria-hidden="true" />
Clear History
</button>
{/if}
</div>
</div>
<!-- Downloads list -->
<div class="space-y-4">
{#each Object.entries(downloadingQuants).filter(([key, progress]) => progress.isDone) as [key, progress]}
{@render downloadItem(key, progress)}
{/each}
</div>
</div>
{/if}
</div>
</div>
{#snippet downloadItem(key, progress)}
{#if key !== "undefined"}
{@const fileStats = getFileStats(progress)}
<div
class="bg-surface-100-900 border-surface-300-700 rounded-lg border p-4"
>
<div class="flex items-start gap-4">
<div class="bg-primary-500/10 mt-1 rounded-full p-2">
{#if progress.isDone}
{#if progress.status.toLowerCase() === "cancelled"}
<Icons.X size={16} class="text-orange-500" />
{:else if progress.status.toLowerCase() === "error"}
<Icons.AlertTriangle
size={16}
class="text-red-500"
/>
{:else}
<Icons.Check size={16} class="text-green-500" />
{/if}
{:else}
<Icons.Download
size={16}
class="text-primary-500 animate-pulse"
/>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="mb-3 flex items-center justify-between">
<h4 class="text-foreground truncate font-semibold">
{progress.modelName}
</h4>
<div class="ml-2 flex items-center gap-2">
{#if !progress.isDone}
<button
class="btn btn-sm preset-filled-error-500"
onclick={() =>
cancelDownload?.(progress.modelName)}
title="Cancel download"
aria-label={`Cancel download of ${progress.modelName}`}
>
<Icons.X size={14} aria-hidden="true" />
Cancel
</button>
{/if}
</div>
</div>
<div class="space-y-3">
<!-- Individual file progress bars -->
<div class="space-y-3">
{#each Object.entries(progress.files) as [fileName, fileProgress]}
<div class="space-y-1">
<div
class="flex items-center justify-between text-xs"
>
<span
class="text-muted-foreground max-w-[60%] truncate font-mono"
title={fileName}
>
{fileName}
</span>
<span
class="text-muted-foreground font-mono"
>
{fileProgress.total > 0
? `${((fileProgress.completed / fileProgress.total) * 100).toFixed(1)}%`
: "0%"}
</span>
</div>
<div class="w-full">
<Progress
value={fileProgress.completed}
max={fileProgress.total}
aria-label={`Download progress for ${fileName}: ${fileProgress.total > 0 ? `${((fileProgress.completed / fileProgress.total) * 100).toFixed(1)}%` : "0%"} complete`}
/>
</div>
{#if fileProgress.total > 0}
<div
class="text-muted-foreground flex justify-end font-mono text-[10px]"
>
{(
fileProgress.completed /
(1024 * 1024)
).toFixed(1)}MB /
{(
fileProgress.total /
(1024 * 1024)
).toFixed(1)}MB
</div>
{/if}
</div>
{/each}
</div>
<div
class="border-surface-300-700 flex items-center justify-between border-t pt-2 text-xs"
>
<div class="flex items-center gap-2">
<div
class="h-2 w-2 rounded-full {progress.isDone
? ''
: 'animate-pulse'} {progress.status.toLowerCase() ===
'canceled'
? 'bg-orange-500'
: ['error', 'cancelled'].includes(
progress.status.toLowerCase()
)
? 'bg-red-500'
: progress.status.toLowerCase() ===
'success' ||
isComplete(progress)
? 'bg-green-500'
: 'bg-blue-500'}"
></div>
<span class="text-muted-foreground font-medium">
{progress.status}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
{/snippet}

View file

@ -0,0 +1,407 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import * as skio from "sveltekit-io"
import { onMount, onDestroy, getContext } from "svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
import { toaster } from "$lib/client/utils/toaster"
import type { ListResponse, ModelDetails, ModelResponse } from "ollama"
import { CONNECTION_TYPE } from "$lib/shared/constants/ConnectionTypes"
interface OllamaModel {
name: string
size: number
digest: string
modified_at: string
details?: {
parameter_size: string
quantization_level: string
}
}
const socket = skio.get()
// State
let installedModels: OllamaModel[] = $state([])
let isConnected = $state(true)
let isLoading = $state(false)
let searchQuery = $state("")
let runningModels: ListResponse["models"] = $state([])
let userCtx: UserCtx = $state(getContext("userCtx"))
let showDeleteModal = $state(false)
let modelToDelete: OllamaModel | null = $state(null)
// Context
let systemSettingsCtx: SystemSettingsCtx = $state(
getContext("systemSettingsCtx")
)
// Filtered models based on search
let filteredModels = $derived(
installedModels
.filter((model) =>
model.name.toLowerCase().includes(searchQuery.toLowerCase())
)
.sort((a, b) => {
// Sort if the model is currently connected
if (currentConnectionModelName === a.name) return -1
if (currentConnectionModelName === b.name) return 1
// Sort by name, then by size
if (a.name < b.name) return -1
if (a.name > b.name) return 1
return a.size - b.size
})
)
let currentConnectionModelName: string | null = $derived.by(() => {
if (userCtx?.user?.activeConnection?.type === CONNECTION_TYPE.OLLAMA) {
const currentName = userCtx.user.activeConnection.model
return currentName
}
return null
})
$effect(() => {
console.log(
"Current connection model name:",
currentConnectionModelName
)
})
// Format file size
function formatSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"]
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString()
}
function isModelRunning(model: OllamaModel): boolean {
const res = runningModels.some((runningModel) => {
return runningModel.name === model.name
})
return res
}
// Check Ollama connection and refresh models
async function refreshModels() {
isLoading = true
socket.emit("ollamaModelsList", {})
socket.emit("ollamaListRunningModels", {})
socket.emit("connectionsList", {})
}
// Delete a model
async function deleteModel(model: OllamaModel) {
if (!isConnected) {
toaster.error({ title: "Not connected to Ollama" })
return
}
if (currentConnectionModelName === model.name) {
toaster.error({
title: "Cannot delete connected model",
description:
"Please choose a different connection before deleting it."
})
return
}
socket.emit("ollamaDeleteModel", { modelName: model.name })
}
// Delete modal handlers
function handleDeleteClick(model: OllamaModel) {
modelToDelete = model
showDeleteModal = true
}
function handleDeleteModalConfirm() {
if (modelToDelete) {
deleteModel(modelToDelete)
}
showDeleteModal = false
modelToDelete = null
}
function handleDeleteModalCancel() {
showDeleteModal = false
modelToDelete = null
}
function connectToModel(model: OllamaModel) {
if (!isConnected) {
toaster.error({ title: "Not connected to Ollama" })
return
}
if (currentConnectionModelName === model.name) {
toaster.error({
title: "Already connected to this model",
description: "Please choose a different model to connect."
})
return
}
socket.emit("ollamaConnectModel", { modelName: model.name })
}
// View model website
function viewModelWebsite(model: OllamaModel) {
const modelName = model.name.split(":")[0] // Remove version if present
// Determine if ollama.com or huggingface.co
if (modelName.includes("hf.co")) {
window.open("https://" + modelName, "_blank")
} else {
window.open(`https://ollama.com/library/${modelName}`, "_blank")
}
}
onMount(() => {
// Socket event listeners
socket.on(
"ollamaModelsList",
(message: Sockets.OllamaModelsList.Response) => {
installedModels = message.models
isLoading = false
console.log("ollamaModelsList", message.models)
}
)
socket.on(
"ollamaDeleteModel",
(message: Sockets.OllamaDeleteModel.Response) => {
if (message.success) {
refreshModels()
toaster.success({ title: "Model deleted successfully" })
} else {
toaster.error({ title: "Failed to delete model" })
}
}
)
socket.on(
"ollamaListRunningModels",
(message: Sockets.OllamaListRunningModels.Response) => {
runningModels = message.models
}
)
socket.on(
"ollamaStopModel",
(message: Sockets.OllamaStopModel.Response) => {
if (message.success) {
toaster.success({ title: "Model stopped successfully" })
refreshModels()
}
}
)
socket.on(
"ollamaConnectModel",
(message: Sockets.OllamaConnectModel.Response) => {
if (message.success) {
toaster.success({ title: "Model connected successfully" })
refreshModels()
}
}
)
// Initial load
refreshModels()
})
onDestroy(() => {
socket.off("ollamaModelsList")
socket.off("ollamaDeleteModel")
socket.off("ollamaListRunningModels")
socket.off("ollamaStopModel")
socket.off("ollamaConnectModel")
})
</script>
<!-- Search for installed models -->
<div class="px-4 py-2">
<div class="flex gap-2">
<div class="relative flex-1">
<Icons.Search
class="text-surface-500 absolute top-1/2 left-3 -translate-y-1/2 transform"
size={16}
/>
<input
type="text"
placeholder="Search installed models..."
class="input w-full pl-10"
aria-label="Search installed models by name"
bind:value={searchQuery}
/>
</div>
<button
class="btn preset-filled-surface-500"
onclick={refreshModels}
title="Refresh models"
aria-label="Refresh installed models list"
>
<Icons.RefreshCw size={16} aria-hidden="true" />
</button>
</div>
</div>
{#if !isConnected}
<div class="p-6 text-center">
<Icons.AlertCircle class="text-error-500 mx-auto mb-4" size={48} />
<h3 class="h4 mb-2">Cannot connect to Ollama</h3>
<p class="mb-4 text-sm opacity-75">
Make sure Ollama is running and accessible at the configured URL.
</p>
<button
class="btn preset-filled-primary-500"
onclick={refreshModels}
aria-label="Try connecting to Ollama again"
>
<Icons.RefreshCw size={16} aria-hidden="true" />
Try Again
</button>
</div>
{:else if isLoading}
<div class="p-6 text-center">
<Icons.Loader2 class="mx-auto mb-4 animate-spin" size={32} />
<p class="text-sm opacity-75">Loading installed models...</p>
</div>
{:else if filteredModels.length === 0}
<div class="p-6 text-center">
<Icons.Package class="text-surface-500 mx-auto mb-4" size={48} />
<h3 class="h4 mb-2">No models installed</h3>
<p class="mb-4 text-sm opacity-75">
Install models from the Available tab to get started.
</p>
</div>
{:else}
<div class="space-y-3 p-4">
{#each filteredModels as model}
{@const isRunning = isModelRunning(model)}
{@const isConnected = currentConnectionModelName === model.name}
<div class="card preset-tonal flex flex-col gap-2 p-4">
<div class="flex items-center justify-between">
<h4 class="font-semibold">
{#if isConnected}
<Icons.Check
size={14}
class="text-success-500 inline-block"
/>
{/if}
{model.name}
</h4>
</div>
<div class="text-surface-600 space-y-1 text-sm">
<div class="flex justify-between">
<span>Size:</span>
<span>{formatSize(model.size)}</span>
</div>
<div class="flex justify-between">
<span>Modified:</span>
<span>{formatDate(model.modified_at)}</span>
</div>
{#if model.details}
<div class="flex justify-between">
<span>Parameters:</span>
<span>{model.details.parameter_size}</span>
</div>
{/if}
{#if isRunning}
<div class="flex justify-between">
<span>Status:</span>
<span
class="preset-filled-success-500 rounded-xl px-2 py-1"
role="status"
aria-label="Model is currently running"
>
Running
</span>
</div>
{/if}
</div>
<div class="flex justify-between gap-2">
<div class="flex gap-2">
<button
class="btn btn-sm preset-filled-success-500"
title="Connect to this model"
aria-label="Connect to model"
disabled={isConnected}
onclick={() => connectToModel(model)}
>
{#if isConnected}
<Icons.Check size={14} /> Connected
{:else}
<Icons.Cable size={14} /> Connect
{/if}
</button>
<button
class="btn btn-sm preset-filled-surface-500"
onclick={() => viewModelWebsite(model)}
title="View model website"
aria-label={`View ${model.name} model website in new tab`}
>
<Icons.ExternalLink size={14} aria-hidden="true" /> View
</button>
</div>
<button
class="btn btn-sm preset-filled-error-500"
onclick={() => handleDeleteClick(model)}
title="Delete model"
aria-label={`Delete ${model.name} model`}
>
<Icons.Trash2 size={14} aria-hidden="true" /> Delete
</button>
</div>
</div>
{/each}
</div>
{/if}
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Delete Model</h2>
</header>
<article>
<p class="opacity-60">
Are you sure you want to delete "{modelToDelete}" from Ollama?
This action cannot be undone.
</p>
<p class="opacity-60">
Any associated connections to this model will be removed.
</p>
</article>
<footer class="flex justify-end gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handleDeleteModalCancel}
>
Cancel
</button>
<button
class="btn preset-filled-error-500"
onclick={handleDeleteModalConfirm}
>
Delete
</button>
</footer>
{/snippet}
</Modal>

View file

@ -0,0 +1,302 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { toaster } from "$lib/client/utils/toaster"
import * as skio from "sveltekit-io"
import { onMount, onDestroy, getContext } from "svelte"
import OllamaIcon from "../icons/OllamaIcon.svelte"
interface OllamaModel {
name: string
size: number
digest: string
modified_at: string
details?: {
parameter_size: string
quantization_level: string
}
}
const socket = skio.get()
// State
let currentVersion = $state("")
let updateAvailable = $state(false)
let latestVersion = $state("")
let isCheckingUpdates = $state(false)
let isSavingBaseUrl = $state(false)
let showDeleteModal = $state(false)
let modelToDelete: OllamaModel | null = $state(null)
let baseUrlField = $state("")
// Context
let systemSettingsCtx: SystemSettingsCtx = $state(
getContext("systemSettingsCtx")
)
$effect(() => {
// Update baseUrl when system settings change
baseUrlField = systemSettingsCtx.settings.ollamaManagerBaseUrl
})
// Settings functions
function checkOllamaVersion() {
socket.emit("ollamaVersion", {})
}
function checkForUpdates() {
isCheckingUpdates = true
socket.emit("ollamaIsUpdateAvailable", {})
}
function saveBaseUrl() {
if (!baseUrlField.trim()) {
toaster.error({ title: "Base URL cannot be empty" })
return
}
isSavingBaseUrl = true
socket.emit("ollamaSetBaseUrl", { baseUrl: baseUrlField.trim() })
}
function handleSaveBaseUrl() {
if (!baseUrlField.trim()) {
toaster.error({ title: "Base URL cannot be empty" })
return
}
saveBaseUrl()
}
// Model management functions
function handleDeleteModel(model: OllamaModel) {
modelToDelete = model
showDeleteModal = true
}
function handleDeleteModalConfirm() {
if (modelToDelete) {
socket.emit("ollamaDeleteModel", { modelName: modelToDelete.name })
}
showDeleteModal = false
modelToDelete = null
}
function handleDeleteModalCancel() {
showDeleteModal = false
modelToDelete = null
}
// Format file size
function formatSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"]
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString()
}
onMount(() => {
// Socket event listeners
socket.on(
"ollamaSetBaseUrl",
(message: Sockets.OllamaSetBaseUrl.Response) => {
isSavingBaseUrl = false
if (message.success) {
toaster.success({
title: "Ollama URL updated successfully"
})
} else {
toaster.error({ title: "Failed to update Ollama URL" })
}
}
)
socket.on(
"ollamaVersion",
(message: Sockets.OllamaVersion.Response) => {
currentVersion = message.version || "Unknown"
}
)
socket.on(
"ollamaIsUpdateAvailable",
(message: Sockets.OllamaIsUpdateAvailable.Response) => {
isCheckingUpdates = false
updateAvailable = message.updateAvailable
latestVersion = message.latestVersion || ""
if (message.error) {
toaster.error({
title: "Failed to check for updates",
description: message.error
})
}
}
)
// Load version info when component mounts
checkOllamaVersion()
checkForUpdates()
})
onDestroy(() => {
socket.off("ollamaSetBaseUrl")
socket.off("ollamaVersion")
socket.off("ollamaIsUpdateAvailable")
})
</script>
<div class="space-y-6 p-4">
<!-- Version Information -->
<div class="mt-8 text-center">
<OllamaIcon class="text-muted-foreground mx-auto mb-4 h-16 w-16" />
<span class="h5">Ollama</span>
<!-- Links to documentation and GitHub -->
<div class="mb-6 flex items-center justify-center gap-4">
<a
href="https://ollama.ai/docs"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-primary-500 flex items-center gap-1 text-xs transition-colors"
>
<Icons.BookOpen class="h-3 w-3" />
Documentation
</a>
<div class="text-muted-foreground"></div>
<a
href="https://github.com/ollama/ollama"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-primary-500 flex items-center gap-1 text-xs transition-colors"
>
<Icons.Github class="h-3 w-3" />
GitHub
</a>
</div>
</div>
<div class="card bg-surface-100-800 flex flex-col gap-4 p-4">
<div>
<label class="block text-sm font-medium" for="baseUrl">
Ollama Base URL
</label>
<div class="flex gap-2">
<input
id="baseUrl"
name="baseUrl"
type="url"
class="input flex-1"
placeholder="http://localhost:11434"
bind:value={baseUrlField}
/>
<button
class="btn preset-filled-primary-500"
onclick={handleSaveBaseUrl}
disabled={isSavingBaseUrl}
aria-label="Save Ollama base URL"
>
<Icons.Save size={14} aria-hidden="true" />
Save
</button>
</div>
<p class="text-surface-500 mt-1 text-xs">
The URL where Ollama is running. Usually http://localhost:11434
</p>
</div>
<div class="space-y-3">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-surface-600">Current Version:</span>
<span class="font-mono">{currentVersion || "Unknown"}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-surface-600">Latest Version:</span>
<span class="text-warning-500 font-mono">
{latestVersion}
</span>
</div>
</div>
{#if updateAvailable}
<div
class="bg-warning-100 dark:bg-warning-900 border-warning-300 dark:border-warning-700 rounded-lg border p-3"
>
<div class="mb-2 flex items-center gap-2">
<Icons.AlertTriangle
size={16}
class="text-warning-600"
/>
<span
class="text-warning-800 dark:text-warning-200 font-medium"
>
Update Available
</span>
</div>
<p
class="text-warning-700 dark:text-warning-300 mb-3 text-sm"
>
A new version of Ollama is available. Download it to get
the latest features and bug fixes.
</p>
<a
href="https://github.com/ollama/ollama/releases/latest"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-filled-warning-500"
>
<Icons.Download size={14} />
Download Update
</a>
</div>
{:else if currentVersion}
<div
class="bg-success-100 dark:bg-success-900 border-success-300 dark:border-success-700 rounded-lg border p-3"
>
<div class="flex items-center gap-2">
<Icons.Check size={16} class="text-success-600" />
<span
class="text-success-800 dark:text-success-200 font-medium"
>
You're up to date
</span>
</div>
</div>
{/if}
<div class="flex gap-2">
<button
class="btn btn-sm preset-filled-surface-500"
onclick={checkOllamaVersion}
aria-label="Check current Ollama version"
>
<Icons.RefreshCw size={14} aria-hidden="true" />
Check Version
</button>
<button
class="btn btn-sm preset-filled-surface-500"
onclick={checkForUpdates}
disabled={isCheckingUpdates}
aria-label="Check for Ollama updates"
>
{#if isCheckingUpdates}
<Icons.Loader2
size={14}
class="animate-spin"
aria-hidden="true"
/>
Checking...
{:else}
<Icons.Search size={14} aria-hidden="true" />
Check for Updates
{/if}
</button>
</div>
</div>
</div>
</div>

View file

@ -2,8 +2,10 @@
import * as Icons from "@lucide/svelte"
import * as skio from "sveltekit-io"
import { onDestroy, onMount } from "svelte"
import { z } from "zod"
import PersonaUnsavedChangesModal from "../modals/PersonaUnsavedChangesModal.svelte"
import Avatar from "../Avatar.svelte"
import { toaster } from "$lib/client/utils/toaster"
interface EditPersonaData {
id?: number
@ -13,9 +15,23 @@
isDefault?: boolean
position?: number
connections?: string
tags: string[]
_avatarFile?: File | undefined
_avatar?: string
}
// Zod validation schema
const personaSchema = z.object({
name: z.string().min(1, "Name is required").trim(),
description: z.string().min(1, "Description is required").trim(),
avatar: z.string().optional(),
isDefault: z.boolean().optional(),
position: z.number().optional(),
connections: z.string().optional()
})
type ValidationErrors = Record<string, string>
export interface Props {
personaId?: number
isSafeToClose: boolean
@ -30,7 +46,15 @@
onCancel = $bindable()
}: Props = $props()
let hasChanges = $state(false)
const socket = skio.get()
// Tag-related state
let tagsList: SelectTag[] = $state([])
let tagSearchInput = $state("")
let showTagSuggestions = $state(false)
let editPersonaData: EditPersonaData = $state({
id: undefined,
name: "",
@ -39,23 +63,109 @@
isDefault: false,
position: 0,
connections: "",
_avatarFile: undefined
tags: [],
_avatarFile: undefined,
_avatar: ""
})
let originalPersonaData: EditPersonaData = $state({ ...editPersonaData })
let showUnsavedChangesModal = $state(false)
let confirmCloseFormResolve: ((v: boolean) => void) | null = null
let originalPersonaData: EditPersonaData = $state({
id: undefined,
name: "",
avatar: "",
description: "",
isDefault: false,
position: 0,
connections: "",
tags: [],
_avatarFile: undefined,
_avatar: ""
})
let showCancelModal = $state(false)
let validationErrors: ValidationErrors = $state({})
let formContainer: HTMLDivElement
let validationTimeout: NodeJS.Timeout
let mode: "create" | "edit" = $derived.by(() =>
!!editPersonaData.id ? "edit" : "create"
)
let isDataValid = $derived(!!editPersonaData?.name?.trim())
$effect(() => {
isSafeToClose =
JSON.stringify(editPersonaData) ===
JSON.stringify(originalPersonaData)
// Filtered tags for suggestions
let filteredTags = $derived.by(() => {
if (!tagSearchInput)
return tagsList.filter(
(tag) =>
!editPersonaData.tags.some(
(selectedTag) =>
selectedTag.toLowerCase() === tag.name.toLowerCase()
)
)
return tagsList.filter(
(tag) =>
tag.name.toLowerCase().includes(tagSearchInput.toLowerCase()) &&
!editPersonaData.tags.some(
(selectedTag) =>
selectedTag.toLowerCase() === tag.name.toLowerCase()
)
)
})
// Tag helper functions
function addTag(tagName: string) {
const trimmedName = tagName.trim()
if (!trimmedName) return
// Check for case-insensitive duplicates
const isDuplicate = editPersonaData.tags.some(
(existingTag) =>
existingTag.toLowerCase() === trimmedName.toLowerCase()
)
if (isDuplicate) return
editPersonaData.tags = [...editPersonaData.tags, trimmedName]
tagSearchInput = ""
showTagSuggestions = false
}
function removeTag(tagName: string) {
editPersonaData.tags = editPersonaData.tags.filter(
(tag) => tag !== tagName
)
}
function handleTagInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && tagSearchInput.trim()) {
e.preventDefault()
addTag(tagSearchInput)
} else if (e.key === "Escape") {
showTagSuggestions = false
}
}
// Events: avatarChange, save, cancel
function validateFormDebounced() {
clearTimeout(validationTimeout)
validationTimeout = setTimeout(() => {
validateForm()
}, 300) // 300ms debounce
}
function validateForm(): boolean {
const result = personaSchema.safeParse(editPersonaData)
if (result.success) {
validationErrors = {}
return true
} else {
const errors: ValidationErrors = {}
result.error.errors.forEach((error) => {
if (error.path.length > 0) {
errors[error.path[0] as string] = error.message
}
})
validationErrors = errors
return false
}
}
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement | null
if (!input || !input.files || input.files.length === 0) return
@ -72,6 +182,12 @@
}
function onSave() {
// Validate the form first
if (!validateForm()) {
// Validation failed, errors are already set in validationErrors
return
}
if (mode === "create") {
handleCreate()
} else if (mode === "edit" && editPersonaData.id) {
@ -83,6 +199,7 @@
const newPersona = { ...editPersonaData }
const avatarFile = newPersona._avatarFile
delete newPersona._avatarFile
delete newPersona._avatar
socket.emit("createPersona", {
persona: newPersona,
avatarFile
@ -93,107 +210,189 @@
const updatedPersona = { ...editPersonaData }
const avatarFile = updatedPersona._avatarFile
delete updatedPersona._avatarFile
delete updatedPersona._avatar
socket.emit("updatePersona", {
persona: updatedPersona,
avatarFile
})
}
async function closeFormWithCheck() {
if (!isSafeToClose) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseFormResolve = resolve
})
} else {
closeForm()
return true
}
}
function handleUnsavedChangesOnOpenChange(e: { open: boolean }) {
function handleCancelModalOnOpenChange(e: { open: boolean }) {
if (!e.open) {
showUnsavedChangesModal = false
if (confirmCloseFormResolve) confirmCloseFormResolve(false)
showCancelModal = false
}
}
function handleCloseModalDiscard() {
showUnsavedChangesModal = false
isSafeToClose = true
if (confirmCloseFormResolve) confirmCloseFormResolve(true)
closeForm()
}
function handleCloseModalCancel() {
showUnsavedChangesModal = false
if (confirmCloseFormResolve) confirmCloseFormResolve(false)
}
function handleCancel() {
closeFormWithCheck()
if (hasChanges) {
showCancelModal = true
} else {
closeForm()
}
}
function handleCancelModalDiscard() {
showCancelModal = false
closeForm()
}
function handleCancelModalCancel() {
showCancelModal = false
}
function handleKeydown(e: KeyboardEvent) {
// Only handle shortcuts if this form is focused or contains the active element
if (!formContainer?.contains(document.activeElement)) return
// Ctrl+S / Cmd+S to save
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault()
onSave()
}
// Escape to cancel
else if (e.key === "Escape") {
e.preventDefault()
handleCancel()
}
}
// Add debounced validation effect
$effect(() => {
// Only validate if we have some data and it's not the initial empty state
if (
editPersonaData.name ||
editPersonaData.description ||
Object.keys(validationErrors).length > 0
) {
validateFormDebounced()
}
})
$effect(() => {
hasChanges =
JSON.stringify(editPersonaData) !==
JSON.stringify(originalPersonaData)
isSafeToClose = hasChanges
})
onMount(() => {
onCancel = handleCancel
// Add keyboard event listener
document.addEventListener("keydown", handleKeydown)
socket.on("createPersona", (res: Sockets.CreatePersona.Response) => {
isSafeToClose = true
closeForm()
if (res.persona) {
validationErrors = {} // Clear any validation errors on success
toaster.success({
title: "Persona Created",
description: `Persona "${res.persona.name}" created successfully.`
})
closeForm()
}
})
socket.on("updatePersona", (res: Sockets.UpdatePersona.Response) => {
isSafeToClose = true
closeForm()
if (res.persona) {
validationErrors = {} // Clear any validation errors on success
toaster.success({
title: "Persona Updated",
description: `Persona "${res.persona.name}" updated successfully.`
})
closeForm()
}
})
socket.on("tagsList", (msg: any) => {
tagsList = msg.tagsList || []
})
// Load tags list
socket.emit("tagsList", {})
if (personaId) {
socket.once("persona", (message: Sockets.Persona.Response) => {
if (message.persona) {
Object.assign(editPersonaData, message.persona)
Object.assign(originalPersonaData, message.persona)
const personaData = { ...message.persona }
editPersonaData = {
...editPersonaData,
...personaData,
avatar: personaData.avatar ?? "",
description: personaData.description ?? "",
tags: personaData.tags || [],
_avatar: ""
}
originalPersonaData = { ...editPersonaData }
}
})
socket.emit("persona", { id: personaId })
}
})
onDestroy(() => {
socket.off("createPersona")
socket.off("updatePersona")
socket.off("persona")
socket.off("tagsList")
// Remove keyboard event listener and clear timeout
document.removeEventListener("keydown", handleKeydown)
clearTimeout(validationTimeout)
})
</script>
<div
class="h-full rounded-lg"
bind:this={formContainer}
role="dialog"
aria-labelledby="form-title"
aria-modal="false"
>
<h2 class="mb-4 text-lg font-bold">
{mode === "edit" ? `Edit: ${editPersonaData.name}` : "Create Persona"}
</h2>
<div class="mt-4 mb-4 flex gap-2">
<h1 class="mb-4 text-lg font-bold" id="form-title">
{mode === "edit"
? `Edit: ${editPersonaData.name || "Persona"}`
: "Create Persona"}
</h1>
<div class="mt-4 mb-4 flex gap-2" role="group" aria-label="Form actions">
<button
type="button"
class="btn btn-sm preset-filled-surface-500 w-full"
onclick={handleCancel}
aria-describedby="form-title"
>
Cancel
</button>
<button
type="button"
class="btn btn-sm preset-filled-success-500 w-full"
class:preset-filled-success-500={hasChanges}
class:preset-tonal-success={!hasChanges}
onclick={onSave}
disabled={!isDataValid || isSafeToClose}
aria-describedby="form-title"
aria-label={`${mode === "edit" ? "Update" : "Create"} persona${hasChanges ? " (has unsaved changes)" : ""}`}
>
<Icons.Save size={16} />
<Icons.Save size={16} aria-hidden="true" />
{mode === "edit" ? "Update" : "Create"}
</button>
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-4">
<span>
<div class="flex flex-col gap-4" role="form" aria-labelledby="form-title">
<fieldset
class="flex items-center gap-4"
aria-labelledby="avatar-section"
>
<legend id="avatar-section" class="sr-only">Avatar Settings</legend>
<div aria-label="Current avatar preview">
<Avatar
src={editPersonaData._avatar || editPersonaData.avatar}
char={editPersonaData}
/>
</span>
</div>
<div class="flex w-full flex-col gap-2">
<div class="flex w-full items-center justify-center">
<label
for="dropzone-file"
class="flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-600 dark:hover:bg-gray-800"
class="flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-800"
aria-describedby="avatar-help"
>
<div
class="flex w-full flex-col items-center justify-center"
@ -220,7 +419,12 @@
class="hidden"
accept="image/*"
onchange={handleAvatarChange}
aria-describedby="avatar-help"
/>
<div id="avatar-help" class="sr-only">
Upload an image file for the persona avatar.
Supported formats: JPG, PNG, GIF
</div>
</label>
</div>
<button
@ -231,20 +435,23 @@
editPersonaData._avatar = ""
}}
disabled={!editPersonaData._avatarFile}
aria-label="Clear selected avatar image"
>
Clear Selection
</button>
</div>
</div>
<div class="flex flex-col gap-1">
</fieldset>
<fieldset class="flex flex-col gap-1">
<label class="flex gap-1 font-semibold" for="personaName">
Name* <span
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
title="This field will be visible in prompts"
aria-label="This field will be visible in prompts"
>
<Icons.ScanEye
size={16}
class="relative top-[1px] inline"
aria-hidden="true"
/>
</span>
</label>
@ -252,35 +459,165 @@
id="personaName"
type="text"
bind:value={editPersonaData.name}
class="input"
class="input {validationErrors.name
? 'border-red-500 focus:border-red-500'
: ''}"
oninput={() => {
// Clear validation error when user starts typing
if (validationErrors.name) {
const { name, ...rest } = validationErrors
validationErrors = rest
}
}}
aria-required="true"
aria-invalid={validationErrors.name ? "true" : "false"}
aria-describedby={validationErrors.name
? "name-error"
: undefined}
/>
</div>
<div class="flex flex-col gap-2">
{#if validationErrors.name}
<p
class="mt-1 text-sm text-red-500"
id="name-error"
role="alert"
>
{validationErrors.name}
</p>
{/if}
</fieldset>
<fieldset class="flex flex-col gap-2">
<label class="flex gap-1 font-semibold" for="personaDescription">
Description <span
Description* <span
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
title="This field will be visible in prompts"
aria-label="This field will be visible in prompts"
>
<Icons.ScanEye
size={16}
class="relative top-[1px] inline"
aria-hidden="true"
/>
</span>
</label>
<textarea
id="personaDescription"
rows="3"
rows="8"
bind:value={editPersonaData.description}
class="input"
class="input {validationErrors.description
? 'border-red-500 focus:border-red-500'
: ''}"
placeholder="Description..."
aria-label="Persona description"
aria-required="true"
aria-invalid={validationErrors.description ? "true" : "false"}
aria-describedby={validationErrors.description
? "description-error"
: undefined}
oninput={() => {
// Clear validation error when user starts typing
if (validationErrors.description) {
const { description, ...rest } = validationErrors
validationErrors = rest
}
}}
></textarea>
</div>
{#if validationErrors.description}
<p
class="mt-1 text-sm text-red-500"
id="description-error"
role="alert"
>
{validationErrors.description}
</p>
{/if}
</fieldset>
<!-- Tags Section -->
<fieldset class="flex flex-col gap-2">
<label class="font-semibold" for="tagInput">Tags</label>
<div class="relative">
<input
id="tagInput"
type="text"
bind:value={tagSearchInput}
class="input w-full"
placeholder="Add a tag..."
onfocus={() => (showTagSuggestions = true)}
onblur={() =>
setTimeout(() => (showTagSuggestions = false), 200)}
onkeydown={handleTagInputKeydown}
/>
<!-- Tag suggestions dropdown -->
{#if showTagSuggestions && filteredTags.length > 0}
<div
class="bg-surface-100-900 absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded-lg border shadow-lg"
>
{#each filteredTags as tag}
<button
type="button"
class="hover:bg-surface-200-800 w-full px-3 py-2 text-left transition-colors"
onclick={() => addTag(tag.name)}
>
<span
class="chip mr-2 {tag.colorPreset ||
'preset-filled-primary-500'}"
>
{tag.name}
</span>
{#if tag.description}
<span class="text-muted-foreground text-sm">
- {tag.description}
</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Selected tags display -->
{#if editPersonaData.tags.length > 0}
<div class="flex flex-wrap gap-2">
{#each editPersonaData.tags as tagName}
{@const tag = tagsList.find((t) => t.name === tagName)}
<button
type="button"
class="chip {tag?.colorPreset ||
'preset-filled-primary-500'} group relative"
onclick={() => removeTag(tagName)}
title="Click to remove tag"
>
{tagName}
<Icons.X
size={14}
class="ml-1 opacity-60 group-hover:opacity-100"
/>
</button>
{/each}
</div>
{/if}
</fieldset>
</div>
</div>
<PersonaUnsavedChangesModal
open={showUnsavedChangesModal}
onOpenChange={handleUnsavedChangesOnOpenChange}
onConfirm={handleCloseModalDiscard}
onCancel={handleCloseModalCancel}
open={showCancelModal}
onOpenChange={handleCancelModalOnOpenChange}
onConfirm={handleCancelModalDiscard}
onCancel={handleCancelModalCancel}
/>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

View file

@ -4,10 +4,11 @@
import { Avatar, FileUpload, Modal } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import CharacterForm from "../characterForms/CharacterForm.svelte"
import CharacterCreator from "../modals/CharacterCreatorModal.svelte"
import CharacterUnsavedChangesModal from "../modals/CharacterUnsavedChangesModal.svelte"
import { toaster } from "$lib/client/utils/toaster"
import type { SpecV3 } from "@lenml/char-card-reader"
import SidebarListItem from "../SidebarListItem.svelte"
import CharacterListItem from "../listItems/CharacterListItem.svelte"
interface Props {
onclose?: () => Promise<boolean> | undefined
@ -17,6 +18,9 @@
const socket = skio.get()
const panelsCtx: PanelsCtx = $state(getContext("panelsCtx"))
const systemSettingsCtx: SystemSettingsCtx = $state(
getContext("systemSettingsCtx")
)
let characterList: Sockets.CharacterList.Response["characterList"] = $state(
[]
@ -24,7 +28,7 @@
let search = $state("")
let characterId: number | undefined = $state()
let isCreating = $state(false)
let isSafeToCloseCharacterForm = $state(true)
let showCharacterCreator = $state(false)
let showDeleteModal = $state(false)
let characterToDelete: number | undefined = $state(undefined)
let showUnsavedChangesModal = $state(false)
@ -34,17 +38,17 @@
let importingLorebook: SpecV3.Lorebook | null = $state(null)
let importingLorebookCharacter: SelectCharacter | null = $state(null)
let showLorebookImportConfirmationModal = $state(false)
let characterFormHasChanges = $state(false)
let unsavedChanges = $derived.by(() => {
return !isCreating && !characterId ? false : !isSafeToCloseCharacterForm
})
// Note: Despite the name "isSafeToClose", this prop actually tracks when there ARE changes
// It's misnamed in the CharacterForm component - it should be called "hasChanges"
$effect(() => {
if (panelsCtx.digest.characterId) {
// Check if we have unsaved changes
if (
characterId !== panelsCtx.digest.characterId &&
unsavedChanges
characterFormHasChanges
) {
onEditFormCancel?.()
} else {
@ -66,18 +70,43 @@
return 0
})
if (!search) return list
const searchLower = search.toLowerCase()
return list.filter(
(c: Sockets.CharacterList.Response["characterList"][0]) =>
c.name!.toLowerCase().includes(search.toLowerCase()) ||
(c.description &&
c.description
.toLowerCase()
.includes(search.toLowerCase()))
(c: Sockets.CharacterList.Response["characterList"][0]) => {
// Search by name
if (c.name!.toLowerCase().includes(searchLower)) return true
// Search by description
if (c.description && c.description.toLowerCase().includes(searchLower)) return true
// Search by tags
if (c.characterTags) {
const tagMatch = c.characterTags.some((ct: any) =>
ct.tag && ct.tag.name.toLowerCase().includes(searchLower)
)
if (tagMatch) return true
}
return false
}
)
})
function handleCreateClick() {
isCreating = true
// Clear tutorial flag when user interacts with the highlighted button
if (panelsCtx.digest.tutorial) {
panelsCtx.digest.tutorial = false
}
// Check if easy character creation is enabled
if (systemSettingsCtx.settings.enableEasyCharacterCreation) {
showCharacterCreator = true
} else {
// Use regular edit form for creation
isCreating = true
characterId = undefined
}
}
function handleEditClick(id: number) {
@ -110,7 +139,7 @@
}
async function handleOnClose() {
if (unsavedChanges) {
if (characterFormHasChanges) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseSidebarResolve = resolve
@ -220,117 +249,90 @@
})
</script>
<div class="text-foreground h-full p-4">
<div class="text-foreground h-full p-4" role="region" aria-label="Characters management">
{#if isCreating}
<CharacterForm
bind:isSafeToClose={isSafeToCloseCharacterForm}
closeForm={closeCharacterForm}
/>
{:else if characterId}
{#key characterId}
<section aria-label="Create new character">
<CharacterForm
bind:isSafeToClose={isSafeToCloseCharacterForm}
{characterId}
bind:isSafeToClose={characterFormHasChanges}
closeForm={closeCharacterForm}
bind:onCancel={onEditFormCancel}
/>
</section>
{:else if characterId}
{#key characterId}
<section aria-label="Edit character">
<CharacterForm
bind:isSafeToClose={characterFormHasChanges}
{characterId}
closeForm={closeCharacterForm}
bind:onCancel={onEditFormCancel}
/>
</section>
{/key}
{:else}
<div class="mb-2 flex gap-2">
<div class="mb-2 flex gap-2" role="toolbar" aria-label="Character actions">
<button
class="btn btn-sm preset-filled-primary-500"
class="btn btn-sm preset-filled-primary-500 {panelsCtx.digest
.tutorial
? 'ring-primary-500/50 animate-pulse ring-4'
: ''}"
onclick={handleCreateClick}
title="Create New Character"
aria-label="Create new character"
type="button"
>
<Icons.Plus size={16} />
<Icons.Plus size={16} aria-hidden="true" />
</button>
<button
class="btn btn-sm preset-filled-primary-500"
title="Import Character"
onclick={handleImportClick}
aria-label="Import character from file"
type="button"
>
<Icons.Upload size={16} />
<Icons.Upload size={16} aria-hidden="true" />
</button>
<button
class="btn btn-sm preset-filled-primary-500"
title="Export Character"
disabled
aria-label="Export character (coming soon)"
type="button"
>
<Icons.Download size={16} />
<Icons.Download size={16} aria-hidden="true" />
</button>
</div>
<div class="mb-4 flex items-center gap-2">
<label for="character-search" class="sr-only">
Search characters
</label>
<input
id="character-search"
type="text"
placeholder="Search characters..."
placeholder="Search characters, descriptions, tags..."
class="input"
bind:value={search}
aria-label="Search characters by name, description, or tags"
/>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2" role="list" aria-label="Characters list">
{#if filteredCharacters.length === 0}
<div class="text-muted-foreground py-8 text-center w-100 relative">
No characters found.
<div
class="text-muted-foreground relative w-100 py-8 text-center"
role="status"
aria-live="polite"
>
{search ? `No characters found matching "${search}".` : "No characters found."}
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each filteredCharacters as c}
<SidebarListItem
id={c.id}
onclick={() => handleCharacterClick(c)}
<CharacterListItem
character={c}
onclick={handleCharacterClick}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
contentTitle="Go to character chats"
classes={c.isFavorite ? "border border-primary-500" : ""}
>
{#snippet content()}
<Avatar
src={c.avatar || ""}
size="w-[4em] h-[4em] min-w-[4em] min-h-[4em]"
imageClasses="object-cover"
name={c.nickname || c.name!}
>
<Icons.User size={36} />
</Avatar>
<div class="flex gap-2 relative flex-1 min-w-0">
<div class="flex-1 relative min-w-0">
<div class="truncate font-semibold text-left">
{c.nickname || c.name}
</div>
{#if c.description}
<div
class="text-muted-foreground line-clamp-2 text-xs text-left"
>
{c.description}
</div>
{/if}
</div>
</div>
{/snippet}
{#snippet controls()}
<div class="flex flex-col gap-4">
<button
class="btn btn-sm text-primary-500 p-2"
onclick={(e) => {
e.stopPropagation()
handleEditClick(c.id!)
}}
title="Edit Character"
>
<Icons.Edit size={16} />
</button>
<button
class="btn btn-sm text-error-500 p-2"
onclick={(e) => {
e.stopPropagation()
handleDeleteClick(c.id!)
}}
title="Delete Character"
>
<Icons.Trash2 size={16} />
</button>
</div>
{/snippet}
</SidebarListItem>
/>
{/each}
{/if}
</div>
@ -341,26 +343,33 @@
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
role="alertdialog"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
>
{#snippet content()}
<div class="p-6">
<h2 class="mb-2 text-lg font-bold">Delete Character?</h2>
<p class="mb-4">
<h2 id="delete-modal-title" class="mb-2 text-lg font-bold">Delete Character?</h2>
<p id="delete-modal-description" class="mb-4">
Are you sure you want to delete this character? This action
cannot be undone.
</p>
<div class="flex justify-end gap-2">
<div class="flex justify-end gap-2" role="group" aria-label="Delete confirmation actions">
<button
class="btn preset-filled-surface-500"
onclick={cancelDelete}
type="button"
aria-label="Cancel deletion"
>
Cancel
</button>
<button
class="btn preset-filled-error-500"
onclick={confirmDelete}
type="button"
aria-label="Confirm deletion"
>
Delete
</button>
@ -412,7 +421,7 @@
importingLorebook = null
importingLorebookCharacter = null
}}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
@ -458,3 +467,9 @@
onCancel={handleCloseModalCancel}
/>
{/if}
<!-- Character Creator Modal -->
<CharacterCreator
bind:open={showCharacterCreator}
onOpenChange={(e) => (showCharacterCreator = e.open)}
/>

View file

@ -7,8 +7,8 @@
import { goto } from "$app/navigation"
import { toaster } from "$lib/client/utils/toaster"
import { page } from "$app/state"
import Avatar from "../Avatar.svelte"
import SidebarListItem from "../SidebarListItem.svelte"
import ChatListItem from "../listItems/ChatListItem.svelte"
import ChatsUnsavedChangesModal from "../modals/ChatsUnsavedChangesModal.svelte"
interface Props {
onclose?: () => Promise<boolean> | undefined
@ -24,6 +24,9 @@
let searchCharacter: SelectCharacter | null = $state(null)
let searchPersona: SelectPersona | null = $state(null)
let editChatId: number | null = $state(null)
let chatFormHasChanges = $state(false)
let showUnsavedChangesModal = $state(false)
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
const socket = skio.get()
// Filtered chats derived from search
@ -34,18 +37,30 @@
})
async function handleOnClose() {
// Remove "chats-by-characterId" and "chats-by-personaId" from search params
const url = new URL(window.location.href)
url.searchParams.delete("chats-by-characterId")
url.searchParams.delete("chats-by-personaId")
goto(url.toString(), { replaceState: true })
if (chatFormHasChanges) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseSidebarResolve = resolve
})
} else {
// Remove "chats-by-characterId" and "chats-by-personaId" from search params
const url = new URL(window.location.href)
url.searchParams.delete("chats-by-characterId")
url.searchParams.delete("chats-by-personaId")
goto(url.toString(), { replaceState: true })
return true // TODO
return true
}
}
function handleCreateClick(
event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }
) {
// Clear tutorial flag when user interacts with the highlighted button
if (panelsCtx.digest.tutorial) {
panelsCtx.digest.tutorial = false
}
showEditChatForm = true
}
@ -93,6 +108,29 @@
toaster.success({ title: "Chat deleted" })
})
function handleCloseModalDiscard() {
showUnsavedChangesModal = false
// Clear search params when discarding changes and closing
const url = new URL(window.location.href)
url.searchParams.delete("chats-by-characterId")
url.searchParams.delete("chats-by-personaId")
goto(url.toString(), { replaceState: true })
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(true)
}
function handleCloseModalCancel() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
function handleUnsavedChangesOnOpenChange(e: { open: boolean }) {
if (!e.open) {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
}
$effect(() => {
const lower = search.toLowerCase()
@ -124,20 +162,30 @@
const characterNames = (chat.chatCharacters || [])
.map((cc) => cc.character?.name?.toLowerCase() || "")
.join(" ")
const tagNames = (chat.chatTags || [])
.map((ct: any) => ct.tag?.name?.toLowerCase() || "")
.join(" ")
return (
chatName.includes(lower) ||
personaNames.includes(lower) ||
characterNames.includes(lower)
characterNames.includes(lower) ||
tagNames.includes(lower)
)
})
})
$effect(() => {
if (panelsCtx.digest.chatId) {
showEditChatForm = true
editChatId = panelsCtx.digest.chatId
delete panelsCtx.digest.chatId
delete panelsCtx.digest.chatCharacterId
delete panelsCtx.digest.chatPersonaId
}
})
$effect(() => {
if (panelsCtx.digest.chatCharacterId) {
console.log(
"Searching chats by character ID:",
panelsCtx.digest.chatCharacterId
)
searchByCharacterId = panelsCtx.digest.chatCharacterId
}
if (panelsCtx.digest.chatPersonaId) {
@ -147,6 +195,7 @@
delete panelsCtx.digest.chatPersonaId
})
$effect(() => {
if (searchByCharacterId) {
socket.once("character", (msg: Sockets.Character.Response) => {
@ -185,11 +234,18 @@
<div class="text-foreground flex h-full flex-col p-4">
{#if showEditChatForm}
<EditChatForm bind:showEditChatForm bind:editChatId />
<EditChatForm
bind:showEditChatForm
bind:editChatId
bind:hasChanges={chatFormHasChanges}
/>
{:else}
<div class="mb-2 flex gap-2">
<button
class="btn btn-sm preset-filled-primary-500"
class="btn btn-sm preset-filled-primary-500 {panelsCtx.digest
.tutorial
? 'ring-primary-500/50 animate-pulse ring-4'
: ''}"
onclick={handleCreateClick}
title="Create New Chat"
>
@ -200,7 +256,7 @@
<input
class="input w-full"
type="text"
placeholder="Search chats, personas, characters..."
placeholder="Search chats, personas, characters, tags..."
bind:value={search}
/>
</div>
@ -236,116 +292,12 @@
{:else}
<ul class="flex flex-col gap-2">
{#each filteredChats as chat}
{@const avatars = [
...(chat.chatCharacters || []).map(
(cc) => ({
type: "character",
data: cc.character
})
),
...(chat.chatPersonas || []).map(
(cp) => ({
type: "persona",
data: cp.persona
})
)
]}
<SidebarListItem
onclick={(e) => handleChatClick(chat)}
contentTitle="Go to chat"
>
{#snippet content()}
<div class="relative w-fit">
<div
class="relative mr-2 flex flex-shrink-0 flex-grow-0 items-center"
>
{#if avatars.length <= 2}
{#each avatars as avatar, i}
<div
class="inline-block"
style="margin-left: {i === 0
? '0'
: '-0.7em'}; z-index: {10 -
i};"
>
<Avatar
char={avatar.data}
/>
</div>
{/each}
{:else}
{#each avatars.slice(0, 3) as avatar, i}
<div
class="ml-[-2.25em] inline-block first:ml-0"
style="z-index: {10 - i};"
>
<Avatar
char={avatar.data}
/>
</div>
{/each}
{#if avatars.length > 3}
<div
class="preset-tonal-secondary relative z-1 mb-auto aspect-square rounded-full px-1 pt-[0.15em] text-xs select-none"
>
+{avatars.length - 3}
</div>
{/if}
{/if}
</div>
</div>
<div class="flex min-w-0 flex-col">
<div class="truncate font-semibold text-left">
{chat.name || "Untitled Chat"}
</div>
<div
class="text-muted-foreground line-clamp-2 text-xs text-left"
>
{#if chat.chatCharacters?.length}
{chat.chatCharacters
.map(
(cc) =>
cc.character?.nickname ||
cc.character?.name
)
.filter(Boolean)
.join(", ")}
{/if}
{chat.chatPersonas?.length ? "," : ""}
{#if chat.chatPersonas?.length}
{chat.chatPersonas
.map((cp) => cp.persona?.name)
.filter(Boolean)
.join(", ")}
{/if}
</div>
</div>
{/snippet}
{#snippet controls()}
<div class="ml-auto flex gap-4 flex-col">
<button
class="btn btn-sm text-primary-500 p-4"
onclick={() => {
handleEditClick(chat.id!)
}}
title="Edit Character"
>
<Icons.Edit size={16} />
</button>
<button
class="btn btn-sm text-error-500 p-4"
onclick={(e) => {
e.stopPropagation()
handleDeleteClick(chat.id!)
}}
title="Delete Character"
>
<Icons.Trash2 size={16} />
</button>
</div>
{/snippet}
</SidebarListItem>
<ChatListItem
{chat}
onclick={handleChatClick}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
/>
{/each}
</ul>
{/if}
@ -356,7 +308,7 @@
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
@ -383,3 +335,12 @@
</div>
{/snippet}
</Modal>
{#if showUnsavedChangesModal}
<ChatsUnsavedChangesModal
open={showUnsavedChangesModal}
onOpenChange={handleUnsavedChangesOnOpenChange}
onConfirm={handleCloseModalDiscard}
onCancel={handleCloseModalCancel}
/>
{/if}

View file

@ -4,7 +4,6 @@
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
import OllamaForm from "$lib/client/connectionForms/OllamaForm.svelte"
// import ChatGPTForm from "$lib/client/connectionForms/ChatGPTForm.svelte"
import OpenAIForm from "$lib/client/connectionForms/OpenAIForm.svelte"
import LmStudioForm from "$lib/client/connectionForms/LMStudioForm.svelte"
import {
@ -22,14 +21,20 @@
let { onclose = $bindable() }: Props = $props()
let userCtx: UserCtx = getContext("userCtx")
let systemSettingsCtx: SystemSettingsCtx = getContext("systemSettingsCtx")
let panelsCtx: PanelsCtx = getContext("panelsCtx")
const socket = skio.get()
const OAIChatPresets: {name:string, value: number, connectionDefaults: {
baseUrl: string,
promptFormat?: string,
tokenCounter?: string,
const OAIChatPresets: {
name: string
value: number
connectionDefaults: {
baseUrl: string
promptFormat?: string
tokenCounter?: string
extraJson: {
stream: boolean,
stream: boolean
prerenderPrompt: boolean
apiKey: string
}
@ -59,7 +64,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "ollama",
apiKey: "ollama"
}
}
},
@ -87,7 +92,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -101,7 +106,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -115,7 +120,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -129,7 +134,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -143,7 +148,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -157,7 +162,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -171,7 +176,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -185,7 +190,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -199,7 +204,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -213,7 +218,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
}
@ -241,15 +246,67 @@
let newConnectionOAIChatPreset: number | undefined = $state()
let showDeleteModal = $state(false)
// Screen reader announcements
let announcements = $state("")
function announce(message: string) {
announcements = message
// Clear after screen reader has time to read
setTimeout(() => (announcements = ""), 1000)
}
// Focus management
function focusConnectionSelect() {
const select = document.getElementById("connection-select")
if (select) select.focus()
}
function focusNewConnectionName() {
const input = document.getElementById("newConnName")
if (input) input.focus()
}
// Keyboard shortcuts
function handleKeydown(e: KeyboardEvent) {
// Ctrl/Cmd + N to create new connection
if ((e.ctrlKey || e.metaKey) && e.key === "n") {
e.preventDefault()
handleNew()
}
// Escape to close modals
if (e.key === "Escape") {
if (showNewConnectionModal) {
handleNewConnectionCancel()
} else if (showDeleteModal) {
handleDeleteModalCancel()
} else if (showConfirmModal) {
handleModalCancel()
}
}
}
function handleSelectChange(e: Event) {
const selectedId = +(e.target as HTMLSelectElement).value
const selectedConnection = connectionsList.find(
(c) => c.id === selectedId
)
socket.emit("setUserActiveConnection", {
id: +(e.target as HTMLSelectElement).value
id: selectedId
})
if (selectedConnection) {
announce(`Switched to connection: ${selectedConnection.name}`)
}
}
function handleNew() {
newConnectionName = ""
newConnectionType = CONNECTION_TYPES[0].value
showNewConnectionModal = true
// Clear tutorial flag when user interacts with the highlighted button
if (panelsCtx.digest.tutorial) {
panelsCtx.digest.tutorial = false
}
// Focus the name input after modal opens
setTimeout(focusNewConnectionName, 100)
}
function handleNewConnectionConfirm() {
if (!newConnectionName.trim()) {
@ -272,7 +329,7 @@
...(newConnectionType === CONNECTION_TYPE.OPENAI_CHAT
? OAIChatPresets.find(
(p) => p.value === newConnectionOAIChatPreset
)?.connectionDefaults
)?.connectionDefaults
: {})
}
socket.emit("createConnection", { connection: newConn })
@ -342,22 +399,38 @@
"updateConnection",
(msg: Sockets.UpdateConnection.Response) => {
toaster.success({ title: "Connection Updated" })
announce(
`Connection ${connection?.name} has been updated successfully`
)
}
)
socket.on(
"deleteConnection",
(msg: Sockets.DeleteConnection.Response) => {
const deletedName = connection?.name
toaster.success({ title: "Connection Deleted" })
announce(
`Connection ${deletedName} has been permanently deleted`
)
connection = undefined
originalConnection = undefined
}
)
socket.on(
"createConnection",
(msg: Sockets.CreateConnection.Response) => {
toaster.success({ title: "Connection Created" })
announce(
`New connection ${msg.connection?.name} has been created successfully`
)
}
)
socket.on("deleteConnection", (msg: Sockets.DeleteConnection.Response) => {
toaster.success({ title: "Connection Deleted" })
connection = undefined
originalConnection = undefined
})
socket.on("createConnection", (msg: Sockets.CreateConnection.Response) => {
toaster.success({ title: "Connection Created" })
})
socket.emit("connectionsList", {})
if (userCtx.user?.activeConnectionId) {
socket.emit("connection", { id: userCtx.user.activeConnectionId })
}
onclose = handleOnClose
// If ollama, fetch models
if (connection?.type === "ollama" && connection.baseUrl) {
handleRefreshModels()
}
@ -375,41 +448,85 @@
})
</script>
<div class="text-foreground p-4">
<div class="mt-2 mb-2 flex gap-2 sm:mt-0">
<button
type="button"
class="btn btn-sm preset-filled-primary-500"
onclick={handleNew}
>
<Icons.Plus size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-secondary-500"
onclick={handleReset}
disabled={!unsavedChanges}
>
<Icons.RefreshCcw size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-error-500"
onclick={handleDelete}
disabled={!connection}
>
<Icons.X size={16} />
</button>
<div
class="text-foreground p-4"
role="main"
aria-label="AI Connections Management"
onkeydown={handleKeydown}
>
<!-- Screen reader announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{announcements}
</div>
<header class="mb-4">
<h2 class="sr-only">Connection Management</h2>
<div
class="mt-2 mb-2 flex justify-between gap-2 sm:mt-0"
role="toolbar"
aria-label="Connection actions"
>
<div class="gap-2">
<button
type="button"
class="btn btn-sm preset-filled-primary-500 {panelsCtx
.digest.tutorial
? 'ring-primary-500/50 animate-pulse ring-4'
: ''}"
onclick={handleNew}
aria-label="Create new AI connection (Ctrl+N)"
title="Create new AI connection (Ctrl+N)"
>
<Icons.Plus size={16} aria-hidden="true" />
<span class="sr-only">Create New Connection</span>
</button>
<button
type="button"
class="btn btn-sm preset-filled-secondary-500"
onclick={handleReset}
disabled={!unsavedChanges}
aria-label={unsavedChanges
? "Reset unsaved changes"
: "No changes to reset"}
aria-describedby={unsavedChanges ? "reset-help" : undefined}
>
<Icons.RefreshCcw size={16} aria-hidden="true" />
<span class="sr-only">Reset Changes</span>
</button>
{#if unsavedChanges}
<div id="reset-help" class="sr-only">
Resets all unsaved changes to the selected connection
</div>
{/if}
<button
type="button"
class="btn btn-sm preset-filled-error-500"
onclick={handleDelete}
disabled={!connection}
aria-label={connection
? `Delete connection ${connection.name}`
: "No connection selected to delete"}
>
<Icons.X size={16} aria-hidden="true" />
<span class="sr-only">Delete Connection</span>
</button>
</div>
</div>
</header>
<div
class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center"
class:hidden={!connectionsList.length}
>
<label for="connection-select" class="sr-only">
Select active AI connection
</label>
<select
id="connection-select"
class="select bg-background border-muted rounded border"
onchange={handleSelectChange}
bind:value={userCtx!.user!.activeConnectionId}
disabled={unsavedChanges}
aria-label="Select active AI connection"
aria-describedby="connection-help"
>
{#each connectionsList as c}
<option value={c.id}>
@ -419,62 +536,74 @@
</option>
{/each}
</select>
<div id="connection-help" class="sr-only">
{unsavedChanges
? "Save or reset changes before switching connections"
: "Choose which AI connection to use for conversations"}
</div>
</div>
{#if !!connection}
{#key connection.id}
<div class="my-4 flex">
<button
type="button"
class="btn btn-sm preset-filled-success-500 w-full"
onclick={handleUpdate}
disabled={!unsavedChanges}
>
<Icons.Save size={16} />
Save
</button>
</div>
<div class="flex flex-col gap-1">
<label class="font-semibold" for="name">Name</label>
<input
id="name"
type="text"
bind:value={connection.name}
class="input"
/>
</div>
{#if connection.type === CONNECTION_TYPE.OLLAMA}
<OllamaForm bind:connection />
{:else if connection.type === CONNECTION_TYPE.OPENAI_CHAT}
<OpenAIForm bind:connection />
{:else if connection.type === CONNECTION_TYPE.LM_STUDIO}
<LmStudioForm bind:connection />
{:else if connection.type === CONNECTION_TYPE.LLAMACPP_COMPLETION}
<LlamaCppForm bind:connection />
{/if}
<div class="mt-4 flex flex-col gap-2">
{#if connection.type === "ollama"}
{#if refreshModelsResult}
<div class="mt-1 text-sm">
{#if refreshModelsResult.models?.length}
<div>
Available Models: {refreshModelsResult.models.join(
", "
)}
</div>
{:else if refreshModelsResult.error}
<span class="text-error">
{refreshModelsResult.error}
</span>
{/if}
</div>
{/if}
<section aria-labelledby="connection-details">
<h3 id="connection-details" class="sr-only">
Connection Details for {connection.name}
</h3>
<div class="my-4 flex">
<button
type="button"
class="btn btn-sm preset-filled-success-500 w-full"
onclick={handleUpdate}
disabled={!unsavedChanges}
aria-label={unsavedChanges
? `Save changes to ${connection.name}`
: "No changes to save"}
aria-describedby="save-status"
>
<Icons.Save size={16} aria-hidden="true" />
Save
</button>
</div>
<div id="save-status" class="sr-only">
{unsavedChanges
? "You have unsaved changes"
: "All changes saved"}
</div>
<div class="flex flex-col gap-1">
<label class="font-semibold" for="connection-name">
Connection Name
</label>
<input
id="connection-name"
type="text"
bind:value={connection.name}
class="input"
aria-describedby="name-help"
aria-required="true"
/>
<div id="name-help" class="sr-only">
Enter a descriptive name for this AI connection
</div>
</div>
{#if connection.type === CONNECTION_TYPE.OLLAMA}
<OllamaForm bind:connection />
{:else if connection.type === CONNECTION_TYPE.OPENAI_CHAT}
<OpenAIForm bind:connection />
{:else if connection.type === CONNECTION_TYPE.LM_STUDIO}
<LmStudioForm bind:connection />
{:else if connection.type === CONNECTION_TYPE.LLAMACPP_COMPLETION}
<LlamaCppForm bind:connection />
{/if}
</div>
</section>
{/key}
{/if}
{#if !connectionsList.length}
<div class="text-muted-foreground py-8 text-center">
No connections found. Create a new connection to get started.
<div
class="text-muted-foreground py-8 text-center"
role="status"
aria-live="polite"
>
<p>No AI connections found.</p>
<p>Create a new connection to get started with AI conversations.</p>
</div>
{/if}
</div>
@ -482,148 +611,208 @@
<Modal
open={showConfirmModal}
onOpenChange={(e) => (showConfirmModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-lg w-full"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Confirm</h2>
</header>
<article>
<p class="opacity-60">
Your connection has unsaved changes. Are you sure you want to
discard them?
</p>
</article>
<footer class="flex justify-end gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handleModalCancel}
>
Cancel
</button>
<button
class="btn preset-filled-error-500"
onclick={handleModalDiscard}
>
Discard
</button>
</footer>
<div
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-desc"
>
<header class="flex justify-between">
<h2 id="confirm-title" class="h2">Confirm Action</h2>
</header>
<article>
<p id="confirm-desc" class="opacity-60">
Your connection has unsaved changes. Are you sure you want
to discard them? This action cannot be undone.
</p>
</article>
<footer class="flex justify-end gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handleModalCancel}
aria-label="Cancel and keep unsaved changes"
>
Cancel
</button>
<button
class="btn preset-filled-error-500"
onclick={handleModalDiscard}
aria-label="Discard all unsaved changes"
>
Discard
</button>
</footer>
</div>
{/snippet}
</Modal>
<Modal
open={showNewConnectionModal}
onOpenChange={(e) => (showNewConnectionModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Create New Connection</h2>
</header>
<article class="flex flex-col gap-2">
<div>
<label class="font-semibold" for="newConnName">Name</label>
<input
id="newConnName"
type="text"
class="input w-full"
bind:value={newConnectionName}
placeholder="Enter a name..."
onkeydown={(e) => {
if (e.key === "Enter" && newConnectionName.trim()) {
handleNewConnectionConfirm()
}
}}
/>
<div
role="dialog"
aria-labelledby="new-conn-title"
aria-describedby="new-conn-desc"
>
<header class="flex justify-between">
<h2 id="new-conn-title" class="h2">Create New AI Connection</h2>
</header>
<div id="new-conn-desc" class="sr-only">
Create a new connection to an AI service for conversations
</div>
<div>
<label class="font-semibold" for="newConnType">Type</label>
<select
id="newConnType"
class="select w-full"
bind:value={newConnectionType}
>
{#each CONNECTION_TYPES as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</div>
{#if newConnectionType === CONNECTION_TYPE.OPENAI_CHAT}
<div class="mt-2">
<label class="font-semibold" for="oaiChatPreset">
Preset
<form
class="flex flex-col gap-2"
onsubmit={(e) => {
e.preventDefault()
handleNewConnectionConfirm()
}}
>
<div>
<label class="font-semibold" for="newConnName">
Connection Name
</label>
<input
id="newConnName"
type="text"
class="input w-full"
bind:value={newConnectionName}
placeholder="Enter a descriptive name..."
aria-required="true"
aria-describedby="name-help-new"
onkeydown={(e) => {
if (e.key === "Enter" && newConnectionName.trim()) {
handleNewConnectionConfirm()
}
}}
/>
<div id="name-help-new" class="sr-only">
Enter a name to identify this AI connection
</div>
</div>
<div>
<label class="font-semibold" for="newConnType">
Connection Type
</label>
<select
id="oaiChatPreset"
id="newConnType"
class="select w-full"
bind:value={newConnectionOAIChatPreset}
bind:value={newConnectionType}
aria-describedby="type-help"
>
{#each OAIChatPresets as preset}
<option value={preset.value}>
{preset.name}
</option>
{#each CONNECTION_TYPES as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
<div id="type-help" class="sr-only">
Choose the type of AI service to connect to
</div>
</div>
{/if}
{#if !!newConnectionType}
{@const connectionType = CONNECTION_TYPES.find(
(t) => t.value === newConnectionType
)}
<div class="bg-surface-500/25 flex flex-col gap-2 rounded p-4 mt-4">
<span class="preset-filled-primary-500 p-2">
Difficulty: {connectionType?.difficulty}
</span>
{@html connectionType?.description}
</div>
{/if}
</article>
<footer class="mt-4 flex justify-end gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handleNewConnectionCancel}
>
Cancel
</button>
<button
class="btn preset-filled-primary-500"
onclick={handleNewConnectionConfirm}
disabled={!newConnectionName.trim()}
>
Create
</button>
</footer>
{#if newConnectionType === CONNECTION_TYPE.OPENAI_CHAT}
<div class="mt-2">
<label class="font-semibold" for="oaiChatPreset">
Service Preset
</label>
<select
id="oaiChatPreset"
class="select w-full"
bind:value={newConnectionOAIChatPreset}
aria-describedby="preset-help"
>
{#each OAIChatPresets as preset}
<option value={preset.value}>
{preset.name}
</option>
{/each}
</select>
<div id="preset-help" class="sr-only">
Choose a preset configuration for this AI service
</div>
</div>
{/if}
{#if !!newConnectionType}
{@const connectionType = CONNECTION_TYPES.find(
(t) => t.value === newConnectionType
)}
<div
class="bg-surface-500/25 mt-4 flex flex-col gap-2 rounded p-4"
>
<span class="preset-filled-primary-500 p-2">
Difficulty: {connectionType?.difficulty}
</span>
{@html connectionType?.description}
</div>
{/if}
</form>
<footer class="mt-4 flex justify-end gap-4">
<button
type="button"
class="btn preset-filled-surface-500"
onclick={handleNewConnectionCancel}
aria-label="Cancel connection creation"
>
Cancel
</button>
<button
type="submit"
class="btn preset-filled-primary-500"
onclick={handleNewConnectionConfirm}
disabled={!newConnectionName.trim()}
aria-label={newConnectionName.trim()
? `Create connection named ${newConnectionName}`
: "Enter a name to create connection"}
>
Create Connection
</button>
</footer>
</div>
{/snippet}
</Modal>
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-lg w-full"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Delete Connection</h2>
</header>
<article>
<p class="opacity-60">
Are you sure you want to delete this connection? This cannot be undone.
</p>
</article>
<footer class="flex justify-end gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handleDeleteModalCancel}
>
Cancel
</button>
<button
class="btn preset-filled-error-500"
onclick={handleDeleteModalConfirm}
>
Delete
</button>
</footer>
<div
role="alertdialog"
aria-labelledby="delete-title"
aria-describedby="delete-desc"
>
<header class="flex justify-between">
<h2 id="delete-title" class="h2">Delete AI Connection</h2>
</header>
<article>
<p id="delete-desc" class="opacity-60">
Are you sure you want to delete the connection "{connection?.name}"?
This action cannot be undone and will permanently remove
this AI connection.
</p>
</article>
<footer class="flex justify-end gap-4">
<button
type="button"
class="btn preset-filled-surface-500"
onclick={handleDeleteModalCancel}
aria-label="Cancel deletion and keep the connection"
>
Cancel
</button>
<button
type="button"
class="btn preset-filled-error-500"
onclick={handleDeleteModalConfirm}
aria-label="Permanently delete this AI connection"
>
Delete Connection
</button>
</footer>
</div>
{/snippet}
</Modal>

View file

@ -1,219 +1,293 @@
<script lang="ts">
import * as skio from "sveltekit-io"
import { getContext, onDestroy, onMount } from "svelte"
import * as Icons from "@lucide/svelte"
import ContextConfigUnsavedChangesModal from "../modals/ContextConfigUnsavedChangesModal.svelte"
import NewNameModal from "../modals/NewNameModal.svelte"
import * as skio from "sveltekit-io"
import { getContext, onDestroy, onMount } from "svelte"
import * as Icons from "@lucide/svelte"
import ContextConfigUnsavedChangesModal from "../modals/ContextConfigUnsavedChangesModal.svelte"
import NewNameModal from "../modals/NewNameModal.svelte"
import { toaster } from "$lib/client/utils/toaster"
import { z } from "zod"
interface Props {
onclose?: () => Promise<boolean> | undefined
}
interface Props {
onclose?: () => Promise<boolean> | undefined
}
let { onclose = $bindable() }: Props = $props()
let { onclose = $bindable() }: Props = $props()
const socket = skio.get()
let userCtx: { user: SelectUser } = getContext("userCtx")
let configsList: Sockets.ContextConfigsList.Response["contextConfigsList"] = $state([])
let selectedConfigId: number | undefined = $state(
userCtx.user.activeContextConfigId || undefined
)
let contextConfig: Sockets.ContextConfig.Response["contextConfig"] = $state(
{} as Sockets.ContextConfig.Response["contextConfig"]
)
let originalData: Sockets.ContextConfig.Response["contextConfig"] = $state(
{} as Sockets.ContextConfig.Response["contextConfig"]
)
let unsavedChanges = $derived(JSON.stringify(contextConfig) !== JSON.stringify(originalData))
let showNewNameModal = $state(false)
let showUnsavedChangesModal = $state(false)
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
let showAdvanced = $state(false)
const socket = skio.get()
let userCtx: { user: SelectUser } = getContext("userCtx")
let configsList: Sockets.ContextConfigsList.Response["contextConfigsList"] =
$state([])
let selectedConfigId: number | undefined = $state(
userCtx.user.activeContextConfigId || undefined
)
let contextConfig: Sockets.ContextConfig.Response["contextConfig"] = $state(
{} as Sockets.ContextConfig.Response["contextConfig"]
)
let originalData: Sockets.ContextConfig.Response["contextConfig"] = $state(
{} as Sockets.ContextConfig.Response["contextConfig"]
)
let unsavedChanges = $derived(
JSON.stringify(contextConfig) !== JSON.stringify(originalData)
)
let showNewNameModal = $state(false)
let showUnsavedChangesModal = $state(false)
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
let showAdvanced = $state(false)
function handleSave() {
socket.emit("updateContextConfig", {
contextConfig
})
// After saving, reload the config from the server
// socket.emit("contextConfig", { id: selectedConfigId })
}
// Zod validation schema
const contextConfigSchema = z.object({
name: z.string().min(1, "Name is required").trim()
})
$effect(() => {
// When selectedConfigId changes, load the config from the server
if (selectedConfigId) {
socket.emit("contextConfig", { id: selectedConfigId })
}
})
type ValidationErrors = Record<string, string>
let validationErrors: ValidationErrors = $state({})
function handleDelete() {
if (contextConfig.isImmutable) {
socket.emit("deleteContextConfig", { id: contextConfig.id })
selectedConfigId = undefined
}
}
function validateForm(): boolean {
const result = contextConfigSchema.safeParse({
name: contextConfig.name
})
function handleReset() {
contextConfig = { ...originalData }
}
if (result.success) {
validationErrors = {}
return true
} else {
const errors: ValidationErrors = {}
result.error.errors.forEach((error) => {
if (error.path.length > 0) {
errors[error.path[0] as string] = error.message
}
})
validationErrors = errors
return false
}
}
function handleNew() {
showNewNameModal = true
}
function handleSave() {
if (!validateForm()) return
socket.emit("updateContextConfig", {
contextConfig
})
// After saving, reload the config from the server
// socket.emit("contextConfig", { id: selectedConfigId })
}
function handleNewNameConfirm(name: string) {
if (!name.trim()) return
const newContextConfig = { ...contextConfig, name: name.trim(), isImmutable: false }
delete newContextConfig.id
socket.emit("createContextConfig", { contextConfig: newContextConfig })
showNewNameModal = false
}
$effect(() => {
// When selectedConfigId changes, load the config from the server
if (selectedConfigId) {
socket.emit("contextConfig", { id: selectedConfigId })
}
})
function handleNewNameCancel() {
showNewNameModal = false
}
function handleDelete() {
if (contextConfig.isImmutable) {
socket.emit("deleteContextConfig", { id: contextConfig.id })
selectedConfigId = undefined
}
}
async function handleOnClose() {
if (unsavedChanges) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseSidebarResolve = resolve
})
} else {
return true
}
}
function handleReset() {
contextConfig = { ...originalData }
}
function handleUnsavedChangesModalConfirm() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(true)
}
function handleUnsavedChangesModalCancel() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
function handleUnsavedChangesModalOpenChange(e: OpenChangeDetails) {
if (!e.open) {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
}
function handleNew() {
showNewNameModal = true
}
$effect(() => {
if (!!selectedConfigId && selectedConfigId !== userCtx.user.activeContextConfigId) {
socket.emit("setUserActiveContextConfig", {
id: selectedConfigId
})
}
})
function handleNewNameConfirm(name: string) {
if (!name.trim()) return
const newContextConfig = {
...contextConfig,
name: name.trim(),
isImmutable: false
}
delete newContextConfig.id
socket.emit("createContextConfig", { contextConfig: newContextConfig })
showNewNameModal = false
}
onMount(() => {
socket.on("contextConfigsList", (msg: Sockets.ContextConfigsList.Response) => {
configsList = msg.contextConfigsList
if (!selectedConfigId && configsList.length > 0) {
selectedConfigId = userCtx.user.activeContextConfigId ?? configsList[0].id
}
})
function handleNewNameCancel() {
showNewNameModal = false
}
socket.on("contextConfig", (msg: Sockets.ContextConfig.Response) => {
contextConfig = { ...msg.contextConfig }
originalData = { ...msg.contextConfig }
})
async function handleOnClose() {
if (unsavedChanges) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseSidebarResolve = resolve
})
} else {
return true
}
}
socket.on("createContextConfig", (msg: Sockets.CreateContextConfig.Response) => {
selectedConfigId = msg.contextConfig.id
})
socket.on("updateContextConfig", (msg: Sockets.UpdateContextConfig.Response) => {
contextConfig = { ...msg.contextConfig }
originalData = { ...msg.contextConfig }
toaster.success({title:"Context config saved successfully."})
})
socket.emit("contextConfigsList", {})
socket.emit("contextConfig", {
id: selectedConfigId
})
onclose = handleOnClose
})
function handleUnsavedChangesModalConfirm() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(true)
}
function handleUnsavedChangesModalCancel() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
function handleUnsavedChangesModalOpenChange(e: OpenChangeDetails) {
if (!e.open) {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
}
onDestroy(() => {
socket.off("contextConfigsList")
socket.off("contextConfig")
socket.off("createContextConfig")
socket.off("updateContextConfig")
onclose = undefined
})
$effect(() => {
if (
!!selectedConfigId &&
selectedConfigId !== userCtx.user.activeContextConfigId
) {
socket.emit("setUserActiveContextConfig", {
id: selectedConfigId
})
}
})
onMount(() => {
socket.on(
"contextConfigsList",
(msg: Sockets.ContextConfigsList.Response) => {
configsList = msg.contextConfigsList
if (!selectedConfigId && configsList.length > 0) {
selectedConfigId =
userCtx.user.activeContextConfigId ?? configsList[0].id
}
}
)
socket.on("contextConfig", (msg: Sockets.ContextConfig.Response) => {
contextConfig = { ...msg.contextConfig }
originalData = { ...msg.contextConfig }
})
socket.on(
"createContextConfig",
(msg: Sockets.CreateContextConfig.Response) => {
selectedConfigId = msg.contextConfig.id
}
)
socket.on(
"updateContextConfig",
(msg: Sockets.UpdateContextConfig.Response) => {
contextConfig = { ...msg.contextConfig }
originalData = { ...msg.contextConfig }
toaster.success({ title: "Context config saved successfully." })
}
)
socket.emit("contextConfigsList", {})
socket.emit("contextConfig", {
id: selectedConfigId
})
onclose = handleOnClose
})
onDestroy(() => {
socket.off("contextConfigsList")
socket.off("contextConfig")
socket.off("createContextConfig")
socket.off("updateContextConfig")
onclose = undefined
})
</script>
<div class="text-foreground h-full p-4">
<div class="mt-2 mb-2 flex gap-2 sm:mt-0">
<button type="button" class="btn btn-sm preset-filled-primary-500" onclick={handleNew}>
<Icons.Plus size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-secondary-500"
onclick={handleReset}
disabled={!unsavedChanges}
>
<Icons.RefreshCcw size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-error-500"
onclick={handleDelete}
disabled={!contextConfig || contextConfig.isImmutable}
>
<Icons.X size={16} />
</button>
</div>
<div class="mb-6 flex items-center gap-2">
<select class="select w-full" bind:value={selectedConfigId} disabled={unsavedChanges}>
{#each configsList.filter((c) => c.isImmutable) as c}
<option value={c.id}>{c.name}{c.isImmutable ? "*" : ""}</option>
{/each}
{#each configsList.filter((c) => !c.isImmutable) as c}
<option value={c.id}>{c.name}{c.isImmutable ? "*" : ""}</option>
{/each}
</select>
</div>
{#if contextConfig}
<div class="mt-4 mb-4 flex w-full justify-end gap-2">
<button
class="btn btn-sm preset-filled-success-500 w-full"
onclick={handleSave}
disabled={contextConfig.isImmutable || !unsavedChanges}>
<Icons.Save size={16} />
Save
</button>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="font-semibold" for="contextName">Name*</label>
<input
id="contextName"
type="text"
bind:value={contextConfig.name}
class="input w-full"
disabled={contextConfig.isImmutable}
/>
</div>
<button
type="button"
class="btn btn-sm preset-filled-surface-500 mt-2 mb-2 w-full"
onclick={() => (showAdvanced = !showAdvanced)}
>
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
</button>
{#if showAdvanced}
<div class="flex flex-col gap-1">
<label class="font-semibold" for="contextTemplate">Template</label>
<textarea
id="template"
rows="20"
bind:value={contextConfig.template}
class="input w-full"
></textarea>
</div>
<!-- <div class="flex flex-col gap-4">
<div class="mt-2 mb-2 flex gap-2 sm:mt-0">
<button
type="button"
class="btn btn-sm preset-filled-primary-500"
onclick={handleNew}
>
<Icons.Plus size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-secondary-500"
onclick={handleReset}
disabled={!unsavedChanges}
>
<Icons.RefreshCcw size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-error-500"
onclick={handleDelete}
disabled={!contextConfig || contextConfig.isImmutable}
>
<Icons.X size={16} />
</button>
</div>
<div class="mb-6 flex items-center gap-2">
<select
class="select w-full"
bind:value={selectedConfigId}
disabled={unsavedChanges}
>
{#each configsList.filter((c) => c.isImmutable) as c}
<option value={c.id}>{c.name}{c.isImmutable ? "*" : ""}</option>
{/each}
{#each configsList.filter((c) => !c.isImmutable) as c}
<option value={c.id}>{c.name}{c.isImmutable ? "*" : ""}</option>
{/each}
</select>
</div>
{#if contextConfig}
<div class="mt-4 mb-4 flex w-full justify-end gap-2">
<button
class="btn btn-sm preset-filled-success-500 w-full"
onclick={handleSave}
disabled={contextConfig.isImmutable || !unsavedChanges}
>
<Icons.Save size={16} />
Save
</button>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="font-semibold" for="contextName">Name*</label>
<input
id="contextName"
type="text"
bind:value={contextConfig.name}
class="input w-full {validationErrors.name
? 'border-red-500'
: ''}"
disabled={contextConfig.isImmutable}
oninput={() => {
if (validationErrors.name) {
const { name, ...rest } = validationErrors
validationErrors = rest
}
}}
/>
{#if validationErrors.name}
<p class="mt-1 text-sm text-red-500" role="alert">
{validationErrors.name}
</p>
{/if}
</div>
<button
type="button"
class="btn btn-sm preset-filled-surface-500 mt-2 mb-2 w-full"
onclick={() => (showAdvanced = !showAdvanced)}
>
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
</button>
{#if showAdvanced}
<div class="flex flex-col gap-1">
<label class="font-semibold" for="contextTemplate">
Template
</label>
<textarea
id="template"
rows="20"
bind:value={contextConfig.template}
class="input w-full"
></textarea>
</div>
<!-- <div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="flex items-center gap-2 font-semibold disabled">
<input
@ -234,23 +308,23 @@
</div>
</div>
</div> -->
{/if}
</div>
{/if}
{/if}
</div>
{/if}
</div>
<ContextConfigUnsavedChangesModal
open={showUnsavedChangesModal}
onOpenChange={handleUnsavedChangesModalOpenChange}
onConfirm={handleUnsavedChangesModalConfirm}
onCancel={handleUnsavedChangesModalCancel}
open={showUnsavedChangesModal}
onOpenChange={handleUnsavedChangesModalOpenChange}
onConfirm={handleUnsavedChangesModalConfirm}
onCancel={handleUnsavedChangesModalCancel}
/>
<NewNameModal
open={showNewNameModal}
onOpenChange={(e) => (showNewNameModal = e.open)}
onConfirm={handleNewNameConfirm}
onCancel={handleNewNameCancel}
title="New Context Config"
description="Your current settings will be copied."
open={showNewNameModal}
onOpenChange={(e) => (showNewNameModal = e.open)}
onConfirm={handleNewNameConfirm}
onCancel={handleNewNameCancel}
title="New Context Config"
description="Your current settings will be copied."
/>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount, tick } from "svelte"
import { getContext, onDestroy, onMount, tick } from "svelte"
import * as skio from "sveltekit-io"
import * as Icons from "@lucide/svelte"
import NewNameModal from "../modals/NewNameModal.svelte"
@ -13,7 +13,7 @@
import HistoryEntryManager from "../lorebookForms/HistoryEntryManager.svelte"
import { toaster } from "$lib/client/utils/toaster"
import type { SpecV3 } from "@lenml/char-card-reader"
import SidebarListItem from "../SidebarListItem.svelte"
import LorebookListItem from "../listItems/LorebookListItem.svelte"
interface Props {
onclose?: () => Promise<boolean> | undefined
@ -44,6 +44,7 @@
let importingBook: SpecV3.Lorebook | undefined = $state(undefined)
let deletingLorebookId: number | undefined = $state(undefined)
let showDeleteConfirmationModal: boolean = $state(false)
let panelsCtx: PanelsCtx = $state(getContext("panelsCtx"))
async function handleOnClose() {
if (tabHasUnsavedChanges) {
@ -250,6 +251,14 @@
deletingLorebookId = undefined
}
$effect(() => {
if (panelsCtx.digest.lorebookId && !!lorebookList.length) {
selectedLorebook = lorebookList.find(l => l.id === panelsCtx.digest.lorebookId) || null
isEditingLorebook = true
delete panelsCtx.digest.lorebookId
}
})
onMount(() => {
socket.on("lorebookList", (msg: Sockets.LorebookList.Response) => {
if (msg.lorebookList) {
@ -324,19 +333,19 @@
{/snippet}
{#snippet content()}
<Tabs.Panel value="lorebook">
{#if editGroup == "lorebook"}
{#if editGroup == "lorebook" && selectedLorebook}
<EditLorebookForm lorebookId={selectedLorebook.id} />
{/if}
</Tabs.Panel>
<Tabs.Panel value="bindings">
{#if editGroup == "bindings"}
{#if editGroup == "bindings" && selectedLorebook}
<LorebookBindingsManager
lorebookId={selectedLorebook.id}
/>
{/if}
</Tabs.Panel>
<Tabs.Panel value="world">
{#if editGroup == "world"}
{#if editGroup == "world" && selectedLorebook}
<WorldLoreManager
lorebookId={selectedLorebook.id}
bind:hasUnsavedChanges={tabHasUnsavedChanges}
@ -344,7 +353,7 @@
{/if}
</Tabs.Panel>
<Tabs.Panel value="characters">
{#if editGroup == "characters"}
{#if editGroup == "characters" && selectedLorebook}
<CharacterLoreManager
lorebookId={selectedLorebook.id}
bind:hasUnsavedChanges={tabHasUnsavedChanges}
@ -352,7 +361,7 @@
{/if}
</Tabs.Panel>
<Tabs.Panel value="history">
{#if editGroup == "history"}
{#if editGroup == "history" && selectedLorebook}
<HistoryEntryManager
lorebookId={selectedLorebook.id}
bind:hasUnsavedChanges={tabHasUnsavedChanges}
@ -401,120 +410,21 @@
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each filteredLorebooks as l}
<SidebarListItem
id={l.id}
onclick={(e) => handleLorebookClick(e, { lorebook: l })}
contentTitle="Edit lorebook"
>
{#snippet content()}
<div class="flex flex-col gap-1 text-left min-w-0">
<div class="truncate font-semibold min-w-0">
{l.name}
</div>
<div
class="text-muted-foreground line-clamp-2 h-[3em] text-xs min-w-0"
>
{l.description || "No description provided."}
</div>
</div>
{/snippet}
{#snippet extraContent()}
<div class="min-w-0 flex-1">
<button
class="btn btn-sm"
class:preset-filled-primary-500={l
.lorebookBindings.length > 0}
class:preset-filled-primary-300-700={l
.lorebookBindings.length === 0}
title={l.lorebookBindings?.length
? "Lorebook Bindings"
: "No Lorebook Bindings"}
onclick={(e) =>
handleLorebookClick(e, {
lorebook: l,
tab: "bindings"
})}
>
<Icons.Link size={16} class="inline" />
{l.lorebookBindings?.length
? l.lorebookBindings.length
: ""}
</button>
<button
class="btn btn-sm"
class:preset-filled-primary-500={l
.worldLoreEntries.length > 0}
class:preset-filled-primary-300-700={l
.worldLoreEntries.length === 0}
title={l.worldLoreEntries?.length
? "World Lore Entries"
: "No World Lore Entries"}
onclick={(e) =>
handleLorebookClick(e, {
lorebook: l,
tab: "world"
})}
>
<Icons.Globe size={16} class="inline" />
{l.worldLoreEntries.length
? l.worldLoreEntries.length
: ""}
</button>
<button
class="btn btn-sm"
class:preset-filled-primary-500={l
.characterLoreEntries.length > 0}
class:preset-filled-primary-300-700={l
.characterLoreEntries.length === 0}
title={l.characterLoreEntries
? "Character Lore Entries"
: "No Character Lore Entries"}
onclick={(e) =>
handleLorebookClick(e, {
lorebook: l,
tab: "characters"
})}
>
<Icons.User size={16} class="inline" />
{l.characterLoreEntries?.length
? l.characterLoreEntries.length
: ""}
</button>
<button
class="btn btn-sm"
class:preset-filled-primary-500={l
.historyEntries.length > 0}
class:preset-filled-primary-300-700={l
.historyEntries.length === 0}
title={l.historyEntries.length
? "History Entries"
: "No History Entries"}
onclick={(e) =>
handleLorebookClick(e, {
lorebook: l,
tab: "history"
})}
>
<Icons.Calendar size={16} class="inline" />
{l.historyEntries?.length
? l.historyEntries.length
: ""}
</button>
</div>
{/snippet}
{#snippet controls()}
<button
class="btn btn-sm text-error-500 p-2"
onclick={(e) => {
e.stopPropagation()
onDeleteClick(l.id)
}}
title="Delete Lorebook"
>
<Icons.Trash2 size={16} />
</button>
{/snippet}
</SidebarListItem>
<LorebookListItem
lorebook={l}
onclick={(lorebook) => handleLorebookClick(new MouseEvent('click'), { lorebook })}
onEdit={(id) => {
const lorebook = lorebookList.find(lb => lb.id === id)
if (lorebook) {
handleLorebookClick(new MouseEvent('click'), { lorebook })
}
}}
onDelete={onDeleteClick}
bindingsCount={l.lorebookBindings?.length || 0}
worldEntriesCount={l.worldLoreEntries?.length || 0}
characterEntriesCount={l.characterLoreEntries?.length || 0}
historyEntriesCount={l.historyEntries?.length || 0}
/>
{/each}
{/if}
</div>
@ -551,7 +461,7 @@
showImportModal = e.open
if (!e.open) importingBook = undefined
}}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
@ -609,7 +519,7 @@
showDeleteConfirmationModal = e.open
if (!e.open) deletingLorebookId = undefined
}}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}

Some files were not shown because too many files have changed in this diff Show more