mirror of
https://github.com/doolijb/serene-pub.git
synced 2026-04-28 03:20:07 +00:00
commit
308f134a72
185 changed files with 44369 additions and 12355 deletions
45
.env.example
45
.env.example
|
|
@ -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
|
||||
296
.github/workflows/release.yml
vendored
296
.github/workflows/release.yml
vendored
|
|
@ -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
34
.vscode/launch.json
vendored
|
|
@ -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
56
.vscode/settings.json
vendored
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
135
CLAUDE.md
Normal 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
227
KEYBINDINGS.md
Normal 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*
|
||||
43
NOTICE.md
43
NOTICE.md
|
|
@ -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
264
README.md
|
|
@ -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 |
|
||||
| --------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |
|
||||
|  |  |  |
|
||||
|
||||
| Prompt Details | Prompts & Chats | Sampling & Personas |
|
||||
| -------------- | --------------- | ------------------- |
|
||||
| Prompt Details | Prompts & Chats | Sampling & Personas |
|
||||
| -------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|
||||
| Theme Example 1 | Theme Example 2 | Theme Example 3 |
|
||||
| --------------- | --------------- | --------------- |
|
||||
| Theme Example 1 | Theme Example 2 | Theme Example 3 |
|
||||
| --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|
||||
| Theme Example 4 | Theme Example 5 |
|
||||
| --------------- | --------------- |
|
||||
| Theme Example 4 | Theme Example 5 |
|
||||
| --------------------------------------------------- | --------------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
### Lorebooks+ & Worldbuilding
|
||||
|
||||
| Character Bindings | Character Lore | Lorebook History | World Lore |
|
||||
| ------------------ | -------------- | ---------------- | ---------- |
|
||||
| Character Bindings | Character Lore | Lorebook History | World Lore |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
|
||||
|  |  |  |  |
|
||||
|
||||
### Ollama Manager
|
||||
|
||||
| Available Models | Downloads | Installed Models | Settings |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
|  |  |  |  |
|
||||
|
||||
### Mobile Experience
|
||||
|
||||
| Chat | Connections | Edit Character |
|
||||
| ---- | ----------- | -------------- |
|
||||
| Chat | Connections | Edit Character |
|
||||
| --------------------------------------- | ---------------------------------------------- | ------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|
||||
| Home | Navigation |
|
||||
| ---- | ---------- |
|
||||
| Home | Navigation |
|
||||
| --------------------------------------- | --------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
6
dist-assets/linux/Serene Pub
Executable 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
|
||||
11
dist-assets/linux/Serene Pub.desktop
Executable file
11
dist-assets/linux/Serene Pub.desktop
Executable 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
|
||||
BIN
dist-assets/linux/favicon.png
Normal file
BIN
dist-assets/linux/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -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
|
||||
|
|
|
|||
10
dist-assets/macos/ICON_SETUP.txt
Normal file
10
dist-assets/macos/ICON_SETUP.txt
Normal 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.
|
||||
|
|
@ -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.)
|
||||
|
|
|
|||
22
dist-assets/macos/Serene Pub.app/Contents/Info.plist
Normal file
22
dist-assets/macos/Serene Pub.app/Contents/Info.plist
Normal 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>
|
||||
7
dist-assets/macos/Serene Pub.app/Contents/MacOS/serene-pub
Executable file
7
dist-assets/macos/Serene Pub.app/Contents/MacOS/serene-pub
Executable 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
|
||||
BIN
dist-assets/macos/favicon.png
Normal file
BIN
dist-assets/macos/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -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
|
||||
|
|
|
|||
9
dist-assets/windows/ICON_SETUP.txt
Normal file
9
dist-assets/windows/ICON_SETUP.txt
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
5
dist-assets/windows/Serene Pub.bat
Normal file
5
dist-assets/windows/Serene Pub.bat
Normal 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
|
||||
BIN
dist-assets/windows/favicon.png
Normal file
BIN
dist-assets/windows/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -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%
|
||||
|
|
|
|||
5
drizzle/0003_past_blue_marvel.sql
Normal file
5
drizzle/0003_past_blue_marvel.sql
Normal 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
|
||||
);
|
||||
10
drizzle/0004_nostalgic_blue_marvel.sql
Normal file
10
drizzle/0004_nostalgic_blue_marvel.sql
Normal 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;
|
||||
3
drizzle/0005_cold_big_bertha.sql
Normal file
3
drizzle/0005_cold_big_bertha.sql
Normal 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;
|
||||
21
drizzle/0006_tiny_power_man.sql
Normal file
21
drizzle/0006_tiny_power_man.sql
Normal 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;
|
||||
38
drizzle/0007_unusual_amphibian.sql
Normal file
38
drizzle/0007_unusual_amphibian.sql
Normal 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)\}';
|
||||
1
drizzle/0008_regular_fat_cobra.sql
Normal file
1
drizzle/0008_regular_fat_cobra.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
-- Custom SQL migration file, put your code below! --
|
||||
1
drizzle/0009_petite_mimic.sql
Normal file
1
drizzle/0009_petite_mimic.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "chat_characters" ADD COLUMN "visibility" text DEFAULT 'visible' NOT NULL;
|
||||
1
drizzle/0010_calm_nightshade.sql
Normal file
1
drizzle/0010_calm_nightshade.sql
Normal 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
1983
drizzle/meta/0003_snapshot.json
Normal file
1983
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1991
drizzle/meta/0004_snapshot.json
Normal file
1991
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2012
drizzle/meta/0005_snapshot.json
Normal file
2012
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2144
drizzle/meta/0006_snapshot.json
Normal file
2144
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2288
drizzle/meta/0007_snapshot.json
Normal file
2288
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2288
drizzle/meta/0008_snapshot.json
Normal file
2288
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2295
drizzle/meta/0009_snapshot.json
Normal file
2295
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2302
drizzle/meta/0010_snapshot.json
Normal file
2302
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
26
package.json
26
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
254
scripts/check-db-lock.js
Normal 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()
|
||||
239
scripts/create-executables.js
Normal file
239
scripts/create-executables.js
Normal 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)
|
||||
|
|
@ -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!")
|
||||
|
|
|
|||
14
src/app.css
14
src/app.css
|
|
@ -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
345
src/app.d.ts
vendored
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
|
||||
11
src/hooks.ts
11
src/hooks.ts
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
31
src/lib/client/components/icons/OllamaIcon.svelte
Normal file
31
src/lib/client/components/icons/OllamaIcon.svelte
Normal file
File diff suppressed because one or more lines are too long
104
src/lib/client/components/listItems/CharacterListItem.svelte
Normal file
104
src/lib/client/components/listItems/CharacterListItem.svelte
Normal 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>
|
||||
132
src/lib/client/components/listItems/ChatListItem.svelte
Normal file
132
src/lib/client/components/listItems/ChatListItem.svelte
Normal 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>
|
||||
137
src/lib/client/components/listItems/LorebookListItem.svelte
Normal file
137
src/lib/client/components/listItems/LorebookListItem.svelte
Normal 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>
|
||||
94
src/lib/client/components/listItems/PersonaListItem.svelte
Normal file
94
src/lib/client/components/listItems/PersonaListItem.svelte
Normal 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
908
src/lib/client/components/modals/CharacterCreatorModal.svelte
Normal file
908
src/lib/client/components/modals/CharacterCreatorModal.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
135
src/lib/client/components/modals/OllamaInstallModal.svelte
Normal file
135
src/lib/client/components/modals/OllamaInstallModal.svelte
Normal 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>
|
||||
106
src/lib/client/components/modals/OllamaInstructionModal.svelte
Normal file
106
src/lib/client/components/modals/OllamaInstructionModal.svelte
Normal 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>
|
||||
146
src/lib/client/components/modals/OllamaManualPullModal.svelte
Normal file
146
src/lib/client/components/modals/OllamaManualPullModal.svelte
Normal 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>
|
||||
655
src/lib/client/components/modals/PersonaCreatorModal.svelte
Normal file
655
src/lib/client/components/modals/PersonaCreatorModal.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
302
src/lib/client/components/ollamaManager/OllamaSettingsTab.svelte
Normal file
302
src/lib/client/components/ollamaManager/OllamaSettingsTab.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue