34 Commits

Author SHA1 Message Date
ab5025694f chore: bump version to 0.4.14
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-07 12:06:44 +01:00
7adf5922ba feat: add prompt template browser and design tool templates
- Add curated prompt templates with categories (coding, writing, analysis,
  creative, assistant) that users can browse and add to their library
- Add "Browse Templates" tab to the Prompts page with category filtering
  and preview functionality
- Add Design Brief Generator tool template for creating structured design
  briefs from project requirements
- Add Color Palette Generator tool template for generating harmonious
  color schemes from a base color

Prompts included: Code Reviewer, Refactoring Expert, Debug Assistant,
API Designer, SQL Expert, Technical Writer, Marketing Copywriter,
UI/UX Advisor, Security Auditor, Data Analyst, Creative Brainstormer,
Storyteller, Concise Assistant, Patient Teacher, Devil's Advocate,
Meeting Summarizer
2026-01-07 12:06:30 +01:00
b656859b10 chore: bump version to 0.4.13
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-07 11:38:50 +01:00
d9b009ce0a feat: add test button for HTTP endpoint tools
Adds the ability to test HTTP endpoint custom tools directly in the
editor, matching the existing test functionality for Python and
JavaScript tools. Closes #6.
2026-01-07 11:38:33 +01:00
c048b1343d chore: bump version to 0.4.12
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-04 01:47:58 +01:00
558c035b84 fix: prevent stream abort and improve attachment handling
- Limit max attachments to 5 files to prevent context overflow
- Fix URL update timing: use SvelteKit's replaceState in onComplete
  callback instead of history.replaceState before streaming
- Load attachment content from IndexedDB in conversation history
  so follow-up messages have access to file content
- Show error messages in chat when Ollama fails instead of stuck
  "Processing..." indicator
- Force file analysis when >3 files attached to reduce context usage
2026-01-04 01:46:26 +01:00
f8fb5ce172 fix: keep processing indicator visible until LLM starts streaming
Clear 'Processing...' text only when first token arrives, not before
the LLM request. This keeps the indicator visible during prompt
resolution, RAG retrieval, and LLM initialization.
2026-01-04 00:41:42 +01:00
4084c9a361 feat: add language instruction to always match user's language
LLM will now respond in the same language the user writes in,
defaulting to English if unclear.
2026-01-04 00:37:14 +01:00
26b58fbd50 feat: improve file attachment handling with processing indicator
- Add "Processing X files..." indicator in chat while handling attachments
- Indicator transitions to "Analyzing X files..." for large files needing LLM summarization
- Reuse streaming message for seamless transition to LLM response
- Add FileAnalyzer service for large file summarization with 10s timeout
- Skip analysis for borderline files (within 20% of 8K threshold)
- Read up to 50KB from original file for analysis (not just truncated content)
- Remove base64 blobs from JSON before analysis to reduce prompt size
- Add AttachmentDisplay component for showing file badges on messages
- Persist attachments to IndexedDB with message references
- Add chat state methods: setStreamContent, removeMessage
- Clean up debug logging
2026-01-04 00:35:33 +01:00
3a4aabff1d fix: bundle PDF.js worker locally to fix CDN loading issues
Some checks failed
Create Release / release (push) Has been cancelled
- Add postinstall script to copy worker to static/
- Update Dockerfile to copy worker during build
- Update file-processor to try local worker first, fallback to CDN
- Bump version to 0.4.11
2026-01-03 22:16:19 +01:00
d94d5ba03a chore: bump version to 0.4.10
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-03 21:52:09 +01:00
75770b1bd8 docs: simplify README, move detailed docs to wiki 2026-01-03 21:35:55 +01:00
edd7c94507 docs: comprehensive documentation update
Updates README with:
- System prompts feature (model-specific, capability-based defaults)
- Custom model creation with embedded prompts
- Comprehensive Custom Tools Guide with examples
- Updated API reference with all endpoints
- Updated roadmap with completed features

Adds detailed documentation for custom tools:
- JavaScript, Python, and HTTP tool types
- Parameter definitions and templates
- Testing workflow and security notes
- Complete weather tool example
- Programmatic tool creation guide
2026-01-03 21:19:32 +01:00
6868027a1c feat: add model-specific prompts and custom model creation
Adds two related features for enhanced model customization:

**Model-Specific System Prompts:**
- Assign prompts to models via Settings > Model Prompts
- Capability-based default prompts (vision, tools, thinking, code)
- Auto-select appropriate prompt when switching models in chat
- Per-model prompt mappings stored in IndexedDB

**Custom Ollama Model Creation:**
- Create custom models with embedded system prompts via Models page
- Edit system prompts of existing custom models
- Streaming progress during model creation
- Visual "Custom" badge for models with embedded prompts
- Backend handler for Ollama /api/create endpoint

New files:
- ModelEditorDialog.svelte: Create/edit dialog for custom models
- model-creation.svelte.ts: State management for model operations
- model-prompt-mappings.svelte.ts: Model-to-prompt mapping store
- model-info-service.ts: Fetches and caches model info from Ollama
- modelfile-parser.ts: Parses system prompts from Modelfiles
2026-01-03 21:12:49 +01:00
1063bec248 chore: bump version to 0.4.9
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-03 18:26:40 +01:00
cf4981f3b2 feat: add auto-compact, settings page, and message virtualization
- Add auto-compact feature with configurable threshold (50-90%)
- Convert settings modal to full /settings page with organized sections
- Add Memory Management settings (auto-compact toggle, threshold, preserve count)
- Add inline SummarizationIndicator shown where compaction occurred
- Add VirtualMessageList with fallback for long conversation performance
- Trigger auto-compact after assistant responses when threshold reached
2026-01-03 18:26:11 +01:00
7cc0df2c78 ci: sync GitHub release notes from Gitea 2026-01-03 15:48:50 +01:00
e19b6330e9 chore: bump version to 0.4.8
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-03 15:32:04 +01:00
c194a4e0e9 fix: include custom tools in Ollama API requests
Custom tools were displayed as enabled in the UI but never sent to
Ollama because getEnabledToolDefinitions() only queried the builtin
tool registry. Now iterates customTools and includes enabled ones.

Fixes #4
2026-01-03 15:29:25 +01:00
04c3018360 chore: bump version to 0.4.7
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 22:42:35 +01:00
2699f1cd5c fix: handle null updates array and show capabilities for local models
- Fix TypeError when check updates returns null updates array
- Display verified capabilities from Ollama runtime in Local Models tab
- Fetch all model capabilities on page mount
- Add data-dev to gitignore
2026-01-02 22:41:37 +01:00
9f313e6599 feat: verify model capabilities from Ollama runtime
Some checks failed
Create Release / release (push) Has been cancelled
- Add capability verification for installed models using /api/show
- SyncModels now updates real capabilities when fetchDetails=true
- Model browser shows verified/unverified badges for capabilities
- Add info notice that capabilities are sourced from ollama.com
- Fix incorrect capability data (e.g., deepseek-r1 "tools" badge)

Capabilities from ollama.com website may be inaccurate. Once a model
is installed, Vessel fetches actual capabilities from Ollama runtime
and displays a "verified" badge in the model details panel.
2026-01-02 22:35:03 +01:00
802db229a6 feat: add model filters and last updated display
Some checks failed
Create Release / release (push) Has been cancelled
- Add size filter (≤3B, 4-13B, 14-70B, >70B) based on model tags
- Add model family filter dropdown with dynamic family list
- Display last updated date on model cards (scraped from ollama.com)
- Add /api/v1/models/remote/families endpoint
- Convert relative time strings ("2 weeks ago") to timestamps during sync
2026-01-02 21:54:50 +01:00
14b566ce2a feat: add DEV_PORT env var for running dev alongside production 2026-01-02 21:17:32 +01:00
7ef29aba37 fix: coerce numeric tool args to handle string values from Ollama
Ollama models sometimes output numbers as strings in tool call arguments.
Go backend strictly rejects string→int coercion, causing errors like:
"cannot unmarshal string into Go struct field URLFetchRequest.maxLength"

- fetch_url: coerce maxLength, timeout
- web_search: coerce maxResults, timeout
- calculate: coerce precision
2026-01-02 21:08:52 +01:00
3c8d811cdc chore: bump version to 0.4.3
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 21:05:03 +01:00
5cab71dd78 fix: sync context progress bar with custom context length setting
- Add customMaxTokens override to ContextManager
- maxTokens is now derived from custom override or model default
- ChatWindow syncs settings.num_ctx to context manager
- Progress bar now shows custom context length when enabled
2026-01-02 21:04:47 +01:00
41bee19f6b chore: bump version to 0.4.2
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 20:54:55 +01:00
f4febf8973 fix: initialize custom parameters from model defaults
- Fetch actual model defaults from Ollama's /api/show endpoint
- Parse YAML-like parameters field (e.g., "temperature 0.7")
- Cache model defaults to avoid repeated API calls
- Initialize sliders with model's actual values when enabling custom params
- Show asterisk indicator when parameter differs from model default
- Reset button now restores to model defaults, not hardcoded values
2026-01-02 20:52:47 +01:00
a552f4a223 chore: bump version to 0.4.1
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 20:36:03 +01:00
4a9e45b40b fix: persist toolCalls to database for reload persistence
Tool usage was not showing after page reload because the toolCalls
field was not being included when saving assistant messages to the
database. Now toolCalls are properly persisted and restored.
2026-01-02 20:34:53 +01:00
862f47c46e feat(tools): enhanced custom tool creation with CodeMirror, Python support, and testing
Some checks failed
Create Release / release (push) Has been cancelled
- Add CodeMirror editor with syntax highlighting for JavaScript and Python
- Add 8 starter templates (4 JS, 4 Python) for common tool patterns
- Add inline documentation panel with language-specific guidance
- Add tool testing UI to run tools with sample inputs before saving
- Add Python tool execution via backend API with 30s timeout
- Add POST /api/v1/tools/execute endpoint for backend tool execution
- Update Dockerfile to include Python 3 for tool execution
- Bump version to 0.4.0
2026-01-02 20:15:40 +01:00
5572cd3a0d ci: auto-create GitHub release on tag push 2026-01-02 19:46:29 +01:00
6426850714 chore: add CLAUDE.md to gitignore 2026-01-02 19:42:47 +01:00
81 changed files with 8814 additions and 1115 deletions

46
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Create Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Wait for Gitea release
run: sleep 60
- name: Fetch release notes from Gitea
id: gitea_notes
env:
TAG_NAME: ${{ github.ref_name }}
run: |
NOTES=$(curl -s "https://somegit.dev/api/v1/repos/vikingowl/vessel/releases/tags/${TAG_NAME}" | jq -r '.body // empty')
if [ -n "$NOTES" ]; then
echo "found=true" >> $GITHUB_OUTPUT
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> $GITHUB_OUTPUT
else
echo "found=false" >> $GITHUB_OUTPUT
echo "notes=See the [full release notes on Gitea](https://somegit.dev/vikingowl/vessel/releases/tag/${TAG_NAME}) for detailed information." >> $GITHUB_OUTPUT
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.gitea_notes.outputs.notes }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

12
.gitignore vendored
View File

@@ -33,3 +33,15 @@ backend/server
# Docker
*.pid
docker-compose.override.yml
# Claude Code project instructions (local only)
CLAUDE.md
# Dev artifacts
dev.env
backend/vessel-backend
data/
backend/data-dev/
# Generated files
frontend/static/pdf.worker.min.mjs

426
README.md
View File

@@ -9,34 +9,23 @@
</p>
<p align="center">
<a href="#why-vessel">Why Vessel</a> •
<a href="#features">Features</a> •
<a href="#screenshots">Screenshots</a> •
<a href="#quick-start">Quick Start</a> •
<a href="#installation">Installation</a> •
<a href="#roadmap">Roadmap</a>
<a href="https://github.com/VikingOwl91/vessel/wiki">Documentation</a> •
<a href="#contributing">Contributing</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/SvelteKit-5.0-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="SvelteKit 5">
<img src="https://img.shields.io/badge/Svelte-5.16-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5">
<img src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.24">
<img src="https://img.shields.io/badge/TypeScript-5.7-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript">
<img src="https://img.shields.io/badge/Tailwind-3.4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind CSS">
<img src="https://img.shields.io/badge/Docker-Ready-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker">
</p>
<p align="center">
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="License GPL-3.0">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square" alt="PRs Welcome">
</p>
---
## Why Vessel
Vessel and [open-webui](https://github.com/open-webui/open-webui) solve different problems.
**Vessel** is intentionally focused on:
- A clean, local-first UI for **Ollama**
@@ -44,52 +33,35 @@ Vessel and [open-webui](https://github.com/open-webui/open-webui) solve differen
- Low visual and cognitive overhead
- Doing a small set of things well
It exists for users who want a UI that is fast and uncluttered, makes browsing and managing Ollama models simple, and stays out of the way once set up.
**open-webui** aims to be a feature-rich, extensible frontend supporting many runtimes, integrations, and workflows. That flexibility is powerful — but it comes with more complexity in setup, UI, and maintenance.
### In short
- If you want a **universal, highly configurable platform** → open-webui is a great choice
- If you want a **small, focused UI for local Ollama usage** → Vessel is built for that
Vessel deliberately avoids becoming a platform. Its scope is narrow by design.
If you want a **universal, highly configurable platform** → [open-webui](https://github.com/open-webui/open-webui) is a great choice.
If you want a **small, focused UI for local Ollama usage** → Vessel is built for that.
---
## Features
### Core Chat Experience
- **Real-time streaming** — Watch responses appear token by token
- **Conversation history** — All chats stored locally in IndexedDB
- **Message editing** — Edit any message and regenerate responses with branching
- **Branch navigation** — Explore different response paths from edited messages
- **Markdown rendering** — Full GFM support with tables, lists, and formatting
- **Syntax highlighting** — Beautiful code blocks powered by Shiki with 100+ languages
- **Dark/Light mode** — Seamless theme switching with system preference detection
### Chat
- Real-time streaming responses
- Message editing with branch navigation
- Markdown rendering with syntax highlighting
- Dark/Light themes
### Built-in Tools (Function Calling)
Vessel includes five powerful tools that models can invoke automatically:
### Tools
- **5 built-in tools**: web search, URL fetching, calculator, location, time
- **Custom tools**: Create your own in JavaScript, Python, or HTTP
- Test tools before saving with the built-in testing panel
| Tool | Description |
|------|-------------|
| **Web Search** | Search the internet for current information, news, weather, prices |
| **Fetch URL** | Read and extract content from any webpage |
| **Calculator** | Safe math expression parser with functions (sqrt, sin, cos, log, etc.) |
| **Get Location** | Detect user location via GPS or IP for local queries |
| **Get Time** | Current date/time with timezone support |
### Models
- Browse and pull models from ollama.com
- Create custom models with embedded system prompts
- Track model updates
### Model Management
- **Model browser** — Browse, search, and pull models from Ollama registry
- **Live status** — See which models are currently loaded in memory
- **Quick switch** — Change models mid-conversation
- **Model metadata** — View parameters, quantization, and capabilities
### Prompts
- Save and organize system prompts
- Assign default prompts to specific models
- Capability-based auto-selection (vision, code, tools, thinking)
### Developer Experience
- **Beautiful code generation** — Syntax-highlighted output for any language
- **Copy code blocks** — One-click copy with visual feedback
- **Scroll to bottom** — Smart auto-scroll with manual override
- **Keyboard shortcuts** — Navigate efficiently with hotkeys
📖 **[Full documentation on the Wiki →](https://github.com/VikingOwl91/vessel/wiki)**
---
@@ -98,33 +70,22 @@ Vessel includes five powerful tools that models can invoke automatically:
<table>
<tr>
<td align="center" width="50%">
<img src="screenshots/hero-dark.png" alt="Chat Interface - Dark Mode">
<br>
<em>Clean, modern chat interface</em>
<img src="screenshots/hero-dark.png" alt="Chat Interface">
<br><em>Clean chat interface</em>
</td>
<td align="center" width="50%">
<img src="screenshots/code-generation.png" alt="Code Generation">
<br>
<em>Syntax-highlighted code output</em>
<br><em>Syntax-highlighted code</em>
</td>
</tr>
<tr>
<td align="center" width="50%">
<img src="screenshots/web-search.png" alt="Web Search Results">
<br>
<em>Integrated web search with styled results</em>
<img src="screenshots/web-search.png" alt="Web Search">
<br><em>Integrated web search</em>
</td>
<td align="center" width="50%">
<img src="screenshots/light-mode.png" alt="Light Mode">
<br>
<em>Light theme for daytime use</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<img src="screenshots/model-browser.png" alt="Model Browser" width="50%">
<br>
<em>Browse and manage Ollama models</em>
<img src="screenshots/model-browser.png" alt="Model Browser">
<br><em>Model browser</em>
</td>
</tr>
</table>
@@ -136,330 +97,107 @@ Vessel includes five powerful tools that models can invoke automatically:
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- [Ollama](https://ollama.com/download) installed and running locally
- [Ollama](https://ollama.com/download) running locally
#### Ollama Configuration
### Configure Ollama
Ollama must listen on all interfaces for Docker containers to connect. Configure it by setting `OLLAMA_HOST=0.0.0.0`:
Ollama must listen on all interfaces for Docker to connect:
**Option A: Using systemd (Linux, recommended)**
```bash
# Option A: systemd (Linux)
sudo systemctl edit ollama
```
Add these lines:
```ini
[Service]
Environment="OLLAMA_HOST=0.0.0.0"
```
Then restart:
```bash
sudo systemctl daemon-reload
# Add: Environment="OLLAMA_HOST=0.0.0.0"
sudo systemctl restart ollama
```
**Option B: Manual start**
```bash
# Option B: Manual
OLLAMA_HOST=0.0.0.0 ollama serve
```
### One-Line Install
### Install
```bash
# One-line install
curl -fsSL https://somegit.dev/vikingowl/vessel/raw/main/install.sh | bash
```
### Or Clone and Run
```bash
git clone https://somegit.dev/vikingowl/vessel.git
# Or clone and run
git clone https://github.com/VikingOwl91/vessel.git
cd vessel
./install.sh
```
The installer will:
- Check for Docker, Docker Compose, and Ollama
- Start the frontend and backend services
- Optionally pull a starter model (llama3.2)
Open **http://localhost:7842** in your browser.
Once running, open **http://localhost:7842** in your browser.
### Update / Uninstall
```bash
./install.sh --update # Update to latest
./install.sh --uninstall # Remove
```
📖 **[Detailed installation guide →](https://github.com/VikingOwl91/vessel/wiki/Getting-Started)**
---
## Installation
## Documentation
### Option 1: Install Script (Recommended)
Full documentation is available on the **[GitHub Wiki](https://github.com/VikingOwl91/vessel/wiki)**:
The install script handles everything automatically:
```bash
./install.sh # Install and start
./install.sh --update # Update to latest version
./install.sh --uninstall # Remove installation
```
**Requirements:**
- Ollama must be installed and running locally
- Docker and Docker Compose
- Linux or macOS
### Option 2: Docker Compose (Manual)
```bash
# Make sure Ollama is running first
ollama serve
# Start Vessel
docker compose up -d
```
### Option 3: Manual Setup (Development)
#### Prerequisites
- [Node.js](https://nodejs.org/) 20+
- [Go](https://go.dev/) 1.24+
- [Ollama](https://ollama.com/) running locally
#### Frontend
```bash
cd frontend
npm install
npm run dev
```
Frontend runs on `http://localhost:5173`
#### Backend
```bash
cd backend
go mod tidy
go run cmd/server/main.go -port 9090
```
Backend API runs on `http://localhost:9090`
---
## Configuration
### Environment Variables
#### Frontend
| Variable | Default | Description |
|----------|---------|-------------|
| `OLLAMA_API_URL` | `http://localhost:11434` | Ollama API endpoint |
| `BACKEND_URL` | `http://localhost:9090` | Vessel backend API |
#### Backend
| Variable | Default | Description |
|----------|---------|-------------|
| `OLLAMA_URL` | `http://localhost:11434` | Ollama API endpoint |
| `PORT` | `8080` | Backend server port |
| `GIN_MODE` | `debug` | Gin mode (`debug`, `release`) |
### Docker Compose Override
Create `docker-compose.override.yml` for local customizations:
```yaml
services:
frontend:
environment:
- CUSTOM_VAR=value
ports:
- "3000:3000" # Different port
```
---
## Architecture
```
vessel/
├── frontend/ # SvelteKit 5 application
│ ├── src/
│ │ ├── lib/
│ │ │ ├── components/ # UI components
│ │ │ ├── stores/ # Svelte 5 runes state
│ │ │ ├── tools/ # Built-in tool definitions
│ │ │ ├── storage/ # IndexedDB (Dexie)
│ │ │ └── api/ # API clients
│ │ └── routes/ # SvelteKit routes
│ └── Dockerfile
├── backend/ # Go API server
│ ├── cmd/server/ # Entry point
│ └── internal/
│ ├── api/ # HTTP handlers
│ │ ├── fetcher.go # URL fetching with wget/curl/chromedp
│ │ ├── search.go # Web search via DuckDuckGo
│ │ └── routes.go # Route definitions
│ ├── database/ # SQLite storage
│ └── models/ # Data models
├── docker-compose.yml # Production setup
└── docker-compose.dev.yml # Development with hot reload
```
---
## Tech Stack
### Frontend
- **[SvelteKit 5](https://kit.svelte.dev/)** — Full-stack framework
- **[Svelte 5](https://svelte.dev/)** — Runes-based reactivity
- **[TypeScript](https://www.typescriptlang.org/)** — Type safety
- **[Tailwind CSS](https://tailwindcss.com/)** — Utility-first styling
- **[Skeleton UI](https://skeleton.dev/)** — Component library
- **[Shiki](https://shiki.matsu.io/)** — Syntax highlighting
- **[Dexie](https://dexie.org/)** — IndexedDB wrapper
- **[Marked](https://marked.js.org/)** — Markdown parser
- **[DOMPurify](https://github.com/cure53/DOMPurify)** — XSS sanitization
### Backend
- **[Go 1.24](https://go.dev/)** — Fast, compiled backend
- **[Gin](https://gin-gonic.com/)** — HTTP framework
- **[SQLite](https://sqlite.org/)** — Embedded database
- **[chromedp](https://github.com/chromedp/chromedp)** — Headless browser
---
## Development
### Running Tests
```bash
# Frontend unit tests
cd frontend
npm run test
# With coverage
npm run test:coverage
# Watch mode
npm run test:watch
```
### Type Checking
```bash
cd frontend
npm run check
```
### Development Mode
Use the dev compose file for hot reloading:
```bash
docker compose -f docker-compose.dev.yml up
```
---
## API Reference
### Backend Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/proxy/search` | Web search via DuckDuckGo |
| `POST` | `/api/v1/proxy/fetch` | Fetch URL content |
| `GET` | `/api/v1/location` | Get user location from IP |
| `GET` | `/api/v1/models/registry` | Browse Ollama model registry |
| `GET` | `/api/v1/models/search` | Search models |
| `POST` | `/api/v1/chats/sync` | Sync conversations |
### Ollama Proxy
All requests to `/ollama/*` are proxied to the Ollama API, enabling CORS.
| Guide | Description |
|-------|-------------|
| [Getting Started](https://github.com/VikingOwl91/vessel/wiki/Getting-Started) | Installation and configuration |
| [Custom Tools](https://github.com/VikingOwl91/vessel/wiki/Custom-Tools) | Create JavaScript, Python, or HTTP tools |
| [System Prompts](https://github.com/VikingOwl91/vessel/wiki/System-Prompts) | Manage prompts with model defaults |
| [Custom Models](https://github.com/VikingOwl91/vessel/wiki/Custom-Models) | Create models with embedded prompts |
| [Built-in Tools](https://github.com/VikingOwl91/vessel/wiki/Built-in-Tools) | Reference for web search, calculator, etc. |
| [API Reference](https://github.com/VikingOwl91/vessel/wiki/API-Reference) | Backend endpoints |
| [Development](https://github.com/VikingOwl91/vessel/wiki/Development) | Contributing and architecture |
| [Troubleshooting](https://github.com/VikingOwl91/vessel/wiki/Troubleshooting) | Common issues and solutions |
---
## Roadmap
Vessel is intentionally focused on being a **clean, local-first UI for Ollama**.
The roadmap prioritizes **usability, clarity, and low friction** over feature breadth.
Vessel prioritizes **usability and simplicity** over feature breadth.
### Core UX Improvements (Near-term)
**Completed:**
- [x] Model browser with filtering and update detection
- [x] Custom tools (JavaScript, Python, HTTP)
- [x] System prompt library with model-specific defaults
- [x] Custom model creation with embedded prompts
These improve the existing experience without expanding scope.
- [ ] Improve model browser & search
- better filtering (size, tags, quantization)
- clearer metadata presentation
**Planned:**
- [ ] Keyboard-first workflows
- model switching
- prompt navigation
- [ ] UX polish & stability
- error handling
- loading / offline states
- small performance improvements
### Local Ecosystem Quality-of-Life (Opt-in)
Still local-first, still focused — but easing onboarding and workflows.
- [ ] Docker-based Ollama support
*(for systems without native Ollama installs)*
- [ ] UX polish and stability improvements
- [ ] Optional voice input/output
*(accessibility & convenience, not a core requirement)*
- [ ] Presets for common workflows
*(model + tool combinations, kept simple)*
### Experimental / Explicitly Optional
**Non-Goals:**
- Multi-user systems
- Cloud sync
- Plugin ecosystems
- Support for every LLM runtime
These are **explorations**, not promises. They are intentionally separated to avoid scope creep.
- [ ] Image generation support
*(only if it can be cleanly isolated from the core UI)*
- [ ] Hugging Face integration
*(evaluated carefully to avoid bloating the local-first experience)*
### Non-Goals (By Design)
Vessel intentionally avoids becoming a platform.
- Multi-user / account-based systems
- Cloud sync or hosted services
- Large plugin ecosystems
- "Universal" support for every LLM runtime
If a feature meaningfully compromises simplicity, it likely doesn't belong in core Vessel.
### Philosophy
> Do one thing well.
> Keep the UI out of the way.
> Prefer clarity over configurability.
> *Do one thing well. Keep the UI out of the way.*
---
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
> Issues and feature requests are tracked on GitHub:
> https://github.com/VikingOwl91/vessel/issues
Contributions are welcome!
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
3. Commit your changes
4. Push and open a Pull Request
📖 **[Development guide →](https://github.com/VikingOwl91/vessel/wiki/Development)**
**Issues:** [github.com/VikingOwl91/vessel/issues](https://github.com/VikingOwl91/vessel/issues)
---
## License
Copyright (C) 2026 VikingOwl
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
---
GPL-3.0 — See [LICENSE](LICENSE) for details.
<p align="center">
Made with <a href="https://ollama.com">Ollama</a> and <a href="https://svelte.dev">Svelte</a>

View File

@@ -18,8 +18,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/serv
# Final stage
FROM alpine:latest
# curl for web fetching, ca-certificates for HTTPS
RUN apk --no-cache add ca-certificates curl
# curl for web fetching, ca-certificates for HTTPS, python3 for tool execution
RUN apk --no-cache add ca-certificates curl python3
WORKDIR /app

View File

@@ -18,7 +18,7 @@ import (
)
// Version is set at build time via -ldflags, or defaults to dev
var Version = "0.3.0"
var Version = "0.4.14"
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {

View File

@@ -70,6 +70,7 @@ type ScrapedModel struct {
PullCount int64
Tags []string
Capabilities []string
UpdatedAt string // Relative time like "2 weeks ago" converted to RFC3339
}
// scrapeOllamaLibrary fetches the model list from ollama.com/library
@@ -168,6 +169,14 @@ func parseLibraryHTML(html string) ([]ScrapedModel, error) {
capabilities = append(capabilities, "cloud")
}
// Extract updated time from <span x-test-updated>2 weeks ago</span>
updatedPattern := regexp.MustCompile(`<span[^>]*x-test-updated[^>]*>([^<]+)</span>`)
updatedAt := ""
if um := updatedPattern.FindStringSubmatch(cardContent); len(um) > 1 {
relativeTime := strings.TrimSpace(um[1])
updatedAt = parseRelativeTime(relativeTime)
}
models[slug] = &ScrapedModel{
Slug: slug,
Name: slug,
@@ -176,6 +185,7 @@ func parseLibraryHTML(html string) ([]ScrapedModel, error) {
PullCount: pullCount,
Tags: tags,
Capabilities: capabilities,
UpdatedAt: updatedAt,
}
}
@@ -211,6 +221,52 @@ func decodeHTMLEntities(s string) string {
return s
}
// parseRelativeTime converts relative time strings like "2 weeks ago" to RFC3339 timestamps
func parseRelativeTime(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ""
}
now := time.Now()
// Parse patterns like "2 weeks ago", "1 month ago", "3 days ago"
pattern := regexp.MustCompile(`(\d+)\s*(second|minute|hour|day|week|month|year)s?\s*ago`)
matches := pattern.FindStringSubmatch(s)
if len(matches) < 3 {
return ""
}
num, err := strconv.Atoi(matches[1])
if err != nil {
return ""
}
unit := matches[2]
var duration time.Duration
switch unit {
case "second":
duration = time.Duration(num) * time.Second
case "minute":
duration = time.Duration(num) * time.Minute
case "hour":
duration = time.Duration(num) * time.Hour
case "day":
duration = time.Duration(num) * 24 * time.Hour
case "week":
duration = time.Duration(num) * 7 * 24 * time.Hour
case "month":
duration = time.Duration(num) * 30 * 24 * time.Hour
case "year":
duration = time.Duration(num) * 365 * 24 * time.Hour
default:
return ""
}
return now.Add(-duration).Format(time.RFC3339)
}
// extractDescription tries to find the description for a model
func extractDescription(html, slug string) string {
// Look for text after the model link that looks like a description
@@ -417,15 +473,16 @@ func (s *ModelRegistryService) SyncModels(ctx context.Context, fetchDetails bool
modelType := inferModelType(model.Slug)
_, err := s.db.ExecContext(ctx, `
INSERT INTO remote_models (slug, name, description, model_type, url, pull_count, tags, capabilities, scraped_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO remote_models (slug, name, description, model_type, url, pull_count, tags, capabilities, ollama_updated_at, scraped_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET
description = COALESCE(NULLIF(excluded.description, ''), remote_models.description),
model_type = excluded.model_type,
pull_count = excluded.pull_count,
capabilities = excluded.capabilities,
ollama_updated_at = COALESCE(excluded.ollama_updated_at, remote_models.ollama_updated_at),
scraped_at = excluded.scraped_at
`, model.Slug, model.Name, model.Description, modelType, model.URL, model.PullCount, string(tagsJSON), string(capsJSON), now)
`, model.Slug, model.Name, model.Description, modelType, model.URL, model.PullCount, string(tagsJSON), string(capsJSON), model.UpdatedAt, now)
if err != nil {
log.Printf("Failed to upsert model %s: %v", model.Slug, err)
@@ -434,6 +491,55 @@ func (s *ModelRegistryService) SyncModels(ctx context.Context, fetchDetails bool
count++
}
// If fetchDetails is true and we have an Ollama client, update capabilities
// for installed models using the actual /api/show response (more accurate than scraped data)
if fetchDetails && s.ollamaClient != nil {
installedModels, err := s.ollamaClient.List(ctx)
if err != nil {
log.Printf("Warning: failed to list installed models for capability sync: %v", err)
} else {
log.Printf("Syncing capabilities for %d installed models", len(installedModels.Models))
for _, installed := range installedModels.Models {
select {
case <-ctx.Done():
return count, ctx.Err()
default:
}
// Extract base model name (e.g., "deepseek-r1" from "deepseek-r1:14b")
modelName := installed.Model
baseName := strings.Split(modelName, ":")[0]
// Fetch real capabilities from Ollama
details, err := s.fetchModelDetails(ctx, modelName)
if err != nil {
log.Printf("Warning: failed to fetch details for %s: %v", modelName, err)
continue
}
// Extract capabilities from the actual Ollama response
capabilities := []string{}
if details.Capabilities != nil {
for _, cap := range details.Capabilities {
capabilities = append(capabilities, string(cap))
}
}
capsJSON, _ := json.Marshal(capabilities)
// Update capabilities for the base model name
_, err = s.db.ExecContext(ctx, `
UPDATE remote_models SET capabilities = ? WHERE slug = ?
`, string(capsJSON), baseName)
if err != nil {
log.Printf("Warning: failed to update capabilities for %s: %v", baseName, err)
} else {
log.Printf("Updated capabilities for %s: %v", baseName, capabilities)
}
}
}
}
return count, nil
}
@@ -572,6 +678,106 @@ func formatParamCount(n int64) string {
return fmt.Sprintf("%d", n)
}
// parseParamSizeToFloat extracts numeric value from parameter size strings like "8b", "70b", "1.5b"
// Returns value in billions (e.g., "8b" -> 8.0, "70b" -> 70.0, "500m" -> 0.5)
func parseParamSizeToFloat(s string) float64 {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return 0
}
// Handle suffix
multiplier := 1.0
if strings.HasSuffix(s, "b") {
s = strings.TrimSuffix(s, "b")
} else if strings.HasSuffix(s, "m") {
s = strings.TrimSuffix(s, "m")
multiplier = 0.001 // Convert millions to billions
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f * multiplier
}
return 0
}
// getSizeRange returns the size range category for a given parameter size
// small: ≤3B, medium: 4-13B, large: 14-70B, xlarge: >70B
func getSizeRange(paramSize string) string {
size := parseParamSizeToFloat(paramSize)
if size <= 0 {
return ""
}
if size <= 3 {
return "small"
}
if size <= 13 {
return "medium"
}
if size <= 70 {
return "large"
}
return "xlarge"
}
// modelMatchesSizeRanges checks if any of the model's tags fall within the requested size ranges
// A model matches if at least one of its tags is in any of the requested ranges
func modelMatchesSizeRanges(tags []string, sizeRanges []string) bool {
if len(tags) == 0 || len(sizeRanges) == 0 {
return false
}
for _, tag := range tags {
tagRange := getSizeRange(tag)
if tagRange == "" {
continue
}
for _, sr := range sizeRanges {
if sr == tagRange {
return true
}
}
}
return false
}
// getContextRange returns the context range category for a given context length
// standard: ≤8K, extended: 8K-32K, large: 32K-128K, unlimited: >128K
func getContextRange(ctxLen int64) string {
if ctxLen <= 0 {
return ""
}
if ctxLen <= 8192 {
return "standard"
}
if ctxLen <= 32768 {
return "extended"
}
if ctxLen <= 131072 {
return "large"
}
return "unlimited"
}
// extractFamily extracts the model family from slug (e.g., "llama3.2" -> "llama", "qwen2.5" -> "qwen")
func extractFamily(slug string) string {
// Remove namespace prefix for community models
if idx := strings.LastIndex(slug, "/"); idx != -1 {
slug = slug[idx+1:]
}
// Extract letters before any digits
family := ""
for _, r := range slug {
if r >= '0' && r <= '9' {
break
}
if r == '-' || r == '_' || r == '.' {
break
}
family += string(r)
}
return strings.ToLower(family)
}
// GetModel retrieves a single model from the database
func (s *ModelRegistryService) GetModel(ctx context.Context, slug string) (*RemoteModel, error) {
row := s.db.QueryRowContext(ctx, `
@@ -584,40 +790,65 @@ func (s *ModelRegistryService) GetModel(ctx context.Context, slug string) (*Remo
return scanRemoteModel(row)
}
// ModelSearchParams holds all search/filter parameters
type ModelSearchParams struct {
Query string
ModelType string
Capabilities []string
SizeRanges []string // small, medium, large, xlarge
ContextRanges []string // standard, extended, large, unlimited
Family string
SortBy string
Limit int
Offset int
}
// SearchModels searches for models in the database
func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, modelType string, capabilities []string, sortBy string, limit, offset int) ([]RemoteModel, int, error) {
return s.SearchModelsAdvanced(ctx, ModelSearchParams{
Query: query,
ModelType: modelType,
Capabilities: capabilities,
SortBy: sortBy,
Limit: limit,
Offset: offset,
})
}
// SearchModelsAdvanced searches for models with all filter options
func (s *ModelRegistryService) SearchModelsAdvanced(ctx context.Context, params ModelSearchParams) ([]RemoteModel, int, error) {
// Build query
baseQuery := `FROM remote_models WHERE 1=1`
args := []any{}
if query != "" {
if params.Query != "" {
baseQuery += ` AND (slug LIKE ? OR name LIKE ? OR description LIKE ?)`
q := "%" + query + "%"
q := "%" + params.Query + "%"
args = append(args, q, q, q)
}
if modelType != "" {
if params.ModelType != "" {
baseQuery += ` AND model_type = ?`
args = append(args, modelType)
args = append(args, params.ModelType)
}
// Filter by capabilities (JSON array contains)
for _, cap := range capabilities {
for _, cap := range params.Capabilities {
// Use JSON contains for SQLite - capabilities column stores JSON array like ["vision","code"]
baseQuery += ` AND capabilities LIKE ?`
args = append(args, `%"`+cap+`"%`)
}
// Get total count
var total int
countQuery := "SELECT COUNT(*) " + baseQuery
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
// Filter by family (extracted from slug)
if params.Family != "" {
// Match slugs that start with the family name
baseQuery += ` AND (slug LIKE ? OR slug LIKE ?)`
args = append(args, params.Family+"%", "%/"+params.Family+"%")
}
// Build ORDER BY clause based on sort parameter
orderBy := "pull_count DESC" // default: most popular
switch sortBy {
switch params.SortBy {
case "name_asc":
orderBy = "name ASC"
case "name_desc":
@@ -630,12 +861,25 @@ func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, m
orderBy = "ollama_updated_at DESC NULLS LAST, scraped_at DESC"
}
// Get models
selectQuery := `SELECT slug, name, description, model_type, architecture, parameter_size,
context_length, embedding_length, quantization, capabilities, default_params,
license, pull_count, tags, tag_sizes, ollama_updated_at, details_fetched_at, scraped_at, url ` +
baseQuery + ` ORDER BY ` + orderBy + ` LIMIT ? OFFSET ?`
args = append(args, limit, offset)
// For size/context filtering, we need to fetch all matching models first
// then filter and paginate in memory (these filters require computed values)
needsPostFilter := len(params.SizeRanges) > 0 || len(params.ContextRanges) > 0
var selectQuery string
if needsPostFilter {
// Fetch all (no limit/offset) for post-filtering
selectQuery = `SELECT slug, name, description, model_type, architecture, parameter_size,
context_length, embedding_length, quantization, capabilities, default_params,
license, pull_count, tags, tag_sizes, ollama_updated_at, details_fetched_at, scraped_at, url ` +
baseQuery + ` ORDER BY ` + orderBy
} else {
// Direct pagination
selectQuery = `SELECT slug, name, description, model_type, architecture, parameter_size,
context_length, embedding_length, quantization, capabilities, default_params,
license, pull_count, tags, tag_sizes, ollama_updated_at, details_fetched_at, scraped_at, url ` +
baseQuery + ` ORDER BY ` + orderBy + ` LIMIT ? OFFSET ?`
args = append(args, params.Limit, params.Offset)
}
rows, err := s.db.QueryContext(ctx, selectQuery, args...)
if err != nil {
@@ -649,10 +893,64 @@ func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, m
if err != nil {
return nil, 0, err
}
// Apply size range filter based on tags
if len(params.SizeRanges) > 0 {
if !modelMatchesSizeRanges(m.Tags, params.SizeRanges) {
continue // Skip models without matching size tags
}
}
// Apply context range filter
if len(params.ContextRanges) > 0 {
modelCtxRange := getContextRange(m.ContextLength)
if modelCtxRange == "" {
continue // Skip models without context info
}
found := false
for _, cr := range params.ContextRanges {
if cr == modelCtxRange {
found = true
break
}
}
if !found {
continue
}
}
models = append(models, *m)
}
return models, total, rows.Err()
if err := rows.Err(); err != nil {
return nil, 0, err
}
// Get total after filtering
total := len(models)
// Apply pagination for post-filtered results
if needsPostFilter {
if params.Offset >= len(models) {
models = []RemoteModel{}
} else {
end := params.Offset + params.Limit
if end > len(models) {
end = len(models)
}
models = models[params.Offset:end]
}
} else {
// Get total count from DB for non-post-filtered queries
countQuery := "SELECT COUNT(*) " + baseQuery
// Remove the limit/offset args we added
countArgs := args[:len(args)-2]
if err := s.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
}
return models, total, nil
}
// GetSyncStatus returns info about when models were last synced
@@ -764,31 +1062,53 @@ func scanRemoteModelRows(rows *sql.Rows) (*RemoteModel, error) {
// ListRemoteModelsHandler returns a handler for listing/searching remote models
func (s *ModelRegistryService) ListRemoteModelsHandler() gin.HandlerFunc {
return func(c *gin.Context) {
query := c.Query("search")
modelType := c.Query("type")
sortBy := c.Query("sort") // name_asc, name_desc, pulls_asc, pulls_desc, updated_desc
limit := 50
offset := 0
params := ModelSearchParams{
Query: c.Query("search"),
ModelType: c.Query("type"),
SortBy: c.Query("sort"), // name_asc, name_desc, pulls_asc, pulls_desc, updated_desc
Family: c.Query("family"),
Limit: 50,
Offset: 0,
}
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 200 {
limit = l
params.Limit = l
}
if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
offset = o
params.Offset = o
}
// Parse capabilities filter (comma-separated)
var capabilities []string
if caps := c.Query("capabilities"); caps != "" {
for _, cap := range strings.Split(caps, ",") {
cap = strings.TrimSpace(cap)
if cap != "" {
capabilities = append(capabilities, cap)
params.Capabilities = append(params.Capabilities, cap)
}
}
}
models, total, err := s.SearchModels(c.Request.Context(), query, modelType, capabilities, sortBy, limit, offset)
// Parse size range filter (comma-separated: small,medium,large,xlarge)
if sizes := c.Query("sizeRange"); sizes != "" {
for _, sz := range strings.Split(sizes, ",") {
sz = strings.TrimSpace(strings.ToLower(sz))
if sz == "small" || sz == "medium" || sz == "large" || sz == "xlarge" {
params.SizeRanges = append(params.SizeRanges, sz)
}
}
}
// Parse context range filter (comma-separated: standard,extended,large,unlimited)
if ctx := c.Query("contextRange"); ctx != "" {
for _, cr := range strings.Split(ctx, ",") {
cr = strings.TrimSpace(strings.ToLower(cr))
if cr == "standard" || cr == "extended" || cr == "large" || cr == "unlimited" {
params.ContextRanges = append(params.ContextRanges, cr)
}
}
}
models, total, err := s.SearchModelsAdvanced(c.Request.Context(), params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -797,8 +1117,8 @@ func (s *ModelRegistryService) ListRemoteModelsHandler() gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{
"models": models,
"total": total,
"limit": limit,
"offset": offset,
"limit": params.Limit,
"offset": params.Offset,
})
}
}
@@ -1138,3 +1458,36 @@ func (s *ModelRegistryService) GetLocalFamiliesHandler() gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"families": families})
}
}
// GetRemoteFamiliesHandler returns unique model families from remote models
// Useful for populating filter dropdowns
func (s *ModelRegistryService) GetRemoteFamiliesHandler() gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := s.db.QueryContext(c.Request.Context(), `SELECT DISTINCT slug FROM remote_models`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
familySet := make(map[string]bool)
for rows.Next() {
var slug string
if err := rows.Scan(&slug); err != nil {
continue
}
family := extractFamily(slug)
if family != "" {
familySet[family] = true
}
}
families := make([]string, 0, len(familySet))
for f := range familySet {
families = append(families, f)
}
sort.Strings(families)
c.JSON(http.StatusOK, gin.H{"families": families})
}
}

View File

@@ -336,6 +336,56 @@ func (s *OllamaService) CopyModelHandler() gin.HandlerFunc {
}
}
// CreateModelHandler handles custom model creation with progress streaming
// Creates a new model derived from an existing one with a custom system prompt
func (s *OllamaService) CreateModelHandler() gin.HandlerFunc {
return func(c *gin.Context) {
var req api.CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
return
}
c.Header("Content-Type", "application/x-ndjson")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
ctx := c.Request.Context()
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
return
}
err := s.client.Create(ctx, &req, func(resp api.ProgressResponse) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
data, err := json.Marshal(resp)
if err != nil {
return err
}
_, err = c.Writer.Write(append(data, '\n'))
if err != nil {
return err
}
flusher.Flush()
return nil
})
if err != nil && err != context.Canceled {
errResp := gin.H{"error": err.Error()}
data, _ := json.Marshal(errResp)
c.Writer.Write(append(data, '\n'))
flusher.Flush()
}
}
}
// VersionHandler returns Ollama version
func (s *OllamaService) VersionHandler() gin.HandlerFunc {
return func(c *gin.Context) {

View File

@@ -66,6 +66,9 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string)
// IP-based geolocation (fallback when browser geolocation fails)
v1.GET("/location", IPGeolocationHandler())
// Tool execution (for Python tools)
v1.POST("/tools/execute", ExecuteToolHandler())
// Model registry routes (cached models from ollama.com)
models := v1.Group("/models")
{
@@ -80,6 +83,8 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string)
// === Remote Models (from ollama.com cache) ===
// List/search remote models (from cache)
models.GET("/remote", modelRegistry.ListRemoteModelsHandler())
// Get unique model families for filter dropdowns
models.GET("/remote/families", modelRegistry.GetRemoteFamiliesHandler())
// Get single model details
models.GET("/remote/:slug", modelRegistry.GetRemoteModelHandler())
// Fetch detailed info from Ollama (requires model to be pulled)
@@ -100,6 +105,7 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string)
ollama.GET("/api/tags", ollamaService.ListModelsHandler())
ollama.POST("/api/show", ollamaService.ShowModelHandler())
ollama.POST("/api/pull", ollamaService.PullModelHandler())
ollama.POST("/api/create", ollamaService.CreateModelHandler())
ollama.DELETE("/api/delete", ollamaService.DeleteModelHandler())
ollama.POST("/api/copy", ollamaService.CopyModelHandler())

View File

@@ -0,0 +1,174 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"os/exec"
"time"
"github.com/gin-gonic/gin"
)
// ExecuteToolRequest represents a tool execution request
type ExecuteToolRequest struct {
Language string `json:"language" binding:"required,oneof=python javascript"`
Code string `json:"code" binding:"required"`
Args map[string]interface{} `json:"args"`
Timeout int `json:"timeout"` // seconds, default 30
}
// ExecuteToolResponse represents the tool execution response
type ExecuteToolResponse struct {
Success bool `json:"success"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
}
// MaxOutputSize is the maximum size of tool output (100KB)
const MaxOutputSize = 100 * 1024
// ExecuteToolHandler handles tool execution requests
func ExecuteToolHandler() gin.HandlerFunc {
return func(c *gin.Context) {
var req ExecuteToolRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ExecuteToolResponse{
Success: false,
Error: "Invalid request: " + err.Error(),
})
return
}
// Default timeout
timeout := req.Timeout
if timeout <= 0 || timeout > 60 {
timeout = 30
}
var resp ExecuteToolResponse
switch req.Language {
case "python":
resp = executePython(req.Code, req.Args, timeout)
case "javascript":
// JavaScript execution not supported on backend (runs in browser)
resp = ExecuteToolResponse{
Success: false,
Error: "JavaScript tools should be executed in the browser",
}
default:
resp = ExecuteToolResponse{
Success: false,
Error: "Unsupported language: " + req.Language,
}
}
c.JSON(http.StatusOK, resp)
}
}
// executePython executes Python code with the given arguments
func executePython(code string, args map[string]interface{}, timeout int) ExecuteToolResponse {
// Create a wrapper script that reads args from stdin
wrapperScript := `
import json
import sys
# Read args from stdin
args = json.loads(sys.stdin.read())
# Execute user code
` + code
// Create temp file for the script
tmpFile, err := os.CreateTemp("", "tool-*.py")
if err != nil {
return ExecuteToolResponse{
Success: false,
Error: "Failed to create temp file: " + err.Error(),
}
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(wrapperScript); err != nil {
return ExecuteToolResponse{
Success: false,
Error: "Failed to write script: " + err.Error(),
}
}
tmpFile.Close()
// Marshal args to JSON for stdin
argsJSON, err := json.Marshal(args)
if err != nil {
return ExecuteToolResponse{
Success: false,
Error: "Failed to serialize args: " + err.Error(),
}
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
// Execute Python (using exec.Command, not shell)
cmd := exec.CommandContext(ctx, "python3", tmpFile.Name())
cmd.Stdin = bytes.NewReader(argsJSON)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
// Check for timeout
if ctx.Err() == context.DeadlineExceeded {
return ExecuteToolResponse{
Success: false,
Error: "Execution timed out after " + string(rune(timeout)) + " seconds",
Stderr: truncateOutput(stderr.String()),
}
}
// Truncate output if needed
stdoutStr := truncateOutput(stdout.String())
stderrStr := truncateOutput(stderr.String())
if err != nil {
return ExecuteToolResponse{
Success: false,
Error: "Execution failed: " + err.Error(),
Stdout: stdoutStr,
Stderr: stderrStr,
}
}
// Try to parse stdout as JSON
var result interface{}
if stdoutStr != "" {
if err := json.Unmarshal([]byte(stdoutStr), &result); err != nil {
// If not valid JSON, return as string
result = stdoutStr
}
}
return ExecuteToolResponse{
Success: true,
Result: result,
Stdout: stdoutStr,
Stderr: stderrStr,
}
}
// truncateOutput truncates output to MaxOutputSize
func truncateOutput(s string) string {
if len(s) > MaxOutputSize {
return s[:MaxOutputSize] + "\n... (output truncated)"
}
return s
}

View File

@@ -123,5 +123,17 @@ func RunMigrations(db *sql.DB) error {
}
}
// Add system_prompt_id column to chats table if it doesn't exist
err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('chats') WHERE name='system_prompt_id'`).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check system_prompt_id column: %w", err)
}
if count == 0 {
_, err = db.Exec(`ALTER TABLE chats ADD COLUMN system_prompt_id TEXT`)
if err != nil {
return fmt.Errorf("failed to add system_prompt_id column: %w", err)
}
}
return nil
}

View File

@@ -10,15 +10,16 @@ import (
// Chat represents a chat conversation
type Chat struct {
ID string `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
Pinned bool `json:"pinned"`
Archived bool `json:"archived"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncVersion int64 `json:"sync_version"`
Messages []Message `json:"messages,omitempty"`
ID string `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
Pinned bool `json:"pinned"`
Archived bool `json:"archived"`
SystemPromptID *string `json:"system_prompt_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncVersion int64 `json:"sync_version"`
Messages []Message `json:"messages,omitempty"`
}
// Message represents a chat message
@@ -54,9 +55,9 @@ func CreateChat(db *sql.DB, chat *Chat) error {
chat.SyncVersion = 1
_, err := db.Exec(`
INSERT INTO chats (id, title, model, pinned, archived, created_at, updated_at, sync_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
chat.ID, chat.Title, chat.Model, chat.Pinned, chat.Archived,
INSERT INTO chats (id, title, model, pinned, archived, system_prompt_id, created_at, updated_at, sync_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
chat.ID, chat.Title, chat.Model, chat.Pinned, chat.Archived, chat.SystemPromptID,
chat.CreatedAt.Format(time.RFC3339), chat.UpdatedAt.Format(time.RFC3339), chat.SyncVersion,
)
if err != nil {
@@ -70,11 +71,12 @@ func GetChat(db *sql.DB, id string) (*Chat, error) {
chat := &Chat{}
var createdAt, updatedAt string
var pinned, archived int
var systemPromptID sql.NullString
err := db.QueryRow(`
SELECT id, title, model, pinned, archived, created_at, updated_at, sync_version
SELECT id, title, model, pinned, archived, system_prompt_id, created_at, updated_at, sync_version
FROM chats WHERE id = ?`, id).Scan(
&chat.ID, &chat.Title, &chat.Model, &pinned, &archived,
&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, &systemPromptID,
&createdAt, &updatedAt, &chat.SyncVersion,
)
if err == sql.ErrNoRows {
@@ -86,6 +88,9 @@ func GetChat(db *sql.DB, id string) (*Chat, error) {
chat.Pinned = pinned == 1
chat.Archived = archived == 1
if systemPromptID.Valid {
chat.SystemPromptID = &systemPromptID.String
}
chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
@@ -102,7 +107,7 @@ func GetChat(db *sql.DB, id string) (*Chat, error) {
// ListChats retrieves all chats ordered by updated_at
func ListChats(db *sql.DB, includeArchived bool) ([]Chat, error) {
query := `
SELECT id, title, model, pinned, archived, created_at, updated_at, sync_version
SELECT id, title, model, pinned, archived, system_prompt_id, created_at, updated_at, sync_version
FROM chats`
if !includeArchived {
query += " WHERE archived = 0"
@@ -120,14 +125,18 @@ func ListChats(db *sql.DB, includeArchived bool) ([]Chat, error) {
var chat Chat
var createdAt, updatedAt string
var pinned, archived int
var systemPromptID sql.NullString
if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived,
if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, &systemPromptID,
&createdAt, &updatedAt, &chat.SyncVersion); err != nil {
return nil, fmt.Errorf("failed to scan chat: %w", err)
}
chat.Pinned = pinned == 1
chat.Archived = archived == 1
if systemPromptID.Valid {
chat.SystemPromptID = &systemPromptID.String
}
chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
chats = append(chats, chat)
@@ -142,10 +151,10 @@ func UpdateChat(db *sql.DB, chat *Chat) error {
chat.SyncVersion++
result, err := db.Exec(`
UPDATE chats SET title = ?, model = ?, pinned = ?, archived = ?,
UPDATE chats SET title = ?, model = ?, pinned = ?, archived = ?, system_prompt_id = ?,
updated_at = ?, sync_version = ?
WHERE id = ?`,
chat.Title, chat.Model, chat.Pinned, chat.Archived,
chat.Title, chat.Model, chat.Pinned, chat.Archived, chat.SystemPromptID,
chat.UpdatedAt.Format(time.RFC3339), chat.SyncVersion, chat.ID,
)
if err != nil {
@@ -234,7 +243,7 @@ func GetMessagesByChatID(db *sql.DB, chatID string) ([]Message, error) {
// GetChangedChats retrieves chats changed since a given sync version
func GetChangedChats(db *sql.DB, sinceVersion int64) ([]Chat, error) {
rows, err := db.Query(`
SELECT id, title, model, pinned, archived, created_at, updated_at, sync_version
SELECT id, title, model, pinned, archived, system_prompt_id, created_at, updated_at, sync_version
FROM chats WHERE sync_version > ? ORDER BY sync_version ASC`, sinceVersion)
if err != nil {
return nil, fmt.Errorf("failed to get changed chats: %w", err)
@@ -246,14 +255,18 @@ func GetChangedChats(db *sql.DB, sinceVersion int64) ([]Chat, error) {
var chat Chat
var createdAt, updatedAt string
var pinned, archived int
var systemPromptID sql.NullString
if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived,
if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, &systemPromptID,
&createdAt, &updatedAt, &chat.SyncVersion); err != nil {
return nil, fmt.Errorf("failed to scan chat: %w", err)
}
chat.Pinned = pinned == 1
chat.Archived = archived == 1
if systemPromptID.Valid {
chat.SystemPromptID = &systemPromptID.String
}
chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
@@ -285,13 +298,14 @@ const (
// GroupedChat represents a chat in a grouped list (without messages for efficiency)
type GroupedChat struct {
ID string `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
Pinned bool `json:"pinned"`
Archived bool `json:"archived"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
Pinned bool `json:"pinned"`
Archived bool `json:"archived"`
SystemPromptID *string `json:"system_prompt_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ChatGroup represents a group of chats with a date label
@@ -349,7 +363,7 @@ func getDateGroup(t time.Time, now time.Time) DateGroup {
func ListChatsGrouped(db *sql.DB, search string, includeArchived bool, limit, offset int) (*GroupedChatsResponse, error) {
// Build query with optional search filter
query := `
SELECT id, title, model, pinned, archived, created_at, updated_at
SELECT id, title, model, pinned, archived, system_prompt_id, created_at, updated_at
FROM chats
WHERE 1=1`
args := []interface{}{}
@@ -390,14 +404,18 @@ func ListChatsGrouped(db *sql.DB, search string, includeArchived bool, limit, of
var chat GroupedChat
var createdAt, updatedAt string
var pinned, archived int
var systemPromptID sql.NullString
if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived,
if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, &systemPromptID,
&createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("failed to scan chat: %w", err)
}
chat.Pinned = pinned == 1
chat.Archived = archived == 1
if systemPromptID.Valid {
chat.SystemPromptID = &systemPromptID.String
}
chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
chats = append(chats, chat)

View File

@@ -12,6 +12,10 @@ RUN npm ci
# Copy source code
COPY . .
# Copy PDF.js worker to static directory for local serving
# This avoids CDN dependency and CORS issues with ESM modules
RUN cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs static/
# Build the application
RUN npm run build

View File

@@ -1,17 +1,24 @@
{
"name": "vessel",
"version": "0.1.0",
"version": "0.4.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vessel",
"version": "0.1.0",
"version": "0.4.8",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.1.7",
"@codemirror/theme-one-dark": "^6.1.2",
"@skeletonlabs/skeleton": "^2.10.0",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-node": "^5.4.0",
"@tanstack/svelte-virtual": "^3.13.15",
"@tanstack/virtual-core": "^3.13.15",
"@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1",
"dexie": "^4.0.10",
"dompurify": "^3.2.0",
"marked": "^15.0.0",
@@ -110,6 +117,137 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
"integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.3.tgz",
"integrity": "sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.39.8",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.8.tgz",
"integrity": "sha512-1rASYd9Z/mE3tkbC9wInRlCNyCkSn+nLsiQKZhEDUUJiUfs/5FHDpCUDaQpoTIaNGeDc6/bhaEAyLmeEucEFPw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"dev": true,
@@ -698,6 +836,69 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz",
"integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.88",
"license": "MIT",
@@ -1540,6 +1741,32 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tanstack/svelte-virtual": {
"version": "3.13.15",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-virtual/-/svelte-virtual-3.13.15.tgz",
"integrity": "sha512-3PPLI3hsyT70zSZhBkSIZXIarlN+GjFNKeKr2Wk1UR7EuEVtXgNlB/Zk0sYtaeJ4CvGvldQNakOvbdETnWAgeA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.15"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"svelte": "^3.48.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.15",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.15.tgz",
"integrity": "sha512-8cG3acM2cSIm3h8WxboHARAhQAJbYUhvmadvnN8uz8aziDwrbYb9KiARni+uY2qrLh49ycn+poGoxvtIAKhjog==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"dev": true,
@@ -2049,6 +2276,21 @@
"node": ">=6"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"license": "MIT",
@@ -2075,6 +2317,12 @@
"node": ">= 0.6"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.1.0",
"dev": true,
@@ -3362,6 +3610,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"license": "MIT",
@@ -3961,6 +4215,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"dev": true,

View File

@@ -1,6 +1,6 @@
{
"name": "vessel",
"version": "0.3.0",
"version": "0.4.14",
"private": true,
"type": "module",
"scripts": {
@@ -11,7 +11,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs static/ 2>/dev/null || true"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
@@ -32,10 +33,17 @@
"vitest": "^4.0.16"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.1.7",
"@codemirror/theme-one-dark": "^6.1.2",
"@skeletonlabs/skeleton": "^2.10.0",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-node": "^5.4.0",
"@tanstack/svelte-virtual": "^3.13.15",
"@tanstack/virtual-core": "^3.13.15",
"@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1",
"dexie": "^4.0.10",
"dompurify": "^3.2.0",
"marked": "^15.0.0",

View File

@@ -49,11 +49,20 @@ export interface SyncStatus {
/** Sort options for model list */
export type ModelSortOption = 'name_asc' | 'name_desc' | 'pulls_asc' | 'pulls_desc' | 'updated_desc';
/** Size range filter options */
export type SizeRange = 'small' | 'medium' | 'large' | 'xlarge';
/** Context length range filter options */
export type ContextRange = 'standard' | 'extended' | 'large' | 'unlimited';
/** Search/filter options */
export interface ModelSearchOptions {
search?: string;
type?: 'official' | 'community';
capabilities?: string[];
sizeRanges?: SizeRange[];
contextRanges?: ContextRange[];
family?: string;
sort?: ModelSortOption;
limit?: number;
offset?: number;
@@ -73,6 +82,13 @@ export async function fetchRemoteModels(options: ModelSearchOptions = {}): Promi
if (options.capabilities && options.capabilities.length > 0) {
params.set('capabilities', options.capabilities.join(','));
}
if (options.sizeRanges && options.sizeRanges.length > 0) {
params.set('sizeRange', options.sizeRanges.join(','));
}
if (options.contextRanges && options.contextRanges.length > 0) {
params.set('contextRange', options.contextRanges.join(','));
}
if (options.family) params.set('family', options.family);
if (options.sort) params.set('sort', options.sort);
if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset));
@@ -87,6 +103,20 @@ export async function fetchRemoteModels(options: ModelSearchOptions = {}): Promi
return response.json();
}
/**
* Get unique model families for filter dropdowns (remote models)
*/
export async function fetchRemoteFamilies(): Promise<string[]> {
const response = await fetch(`${API_BASE}/remote/families`);
if (!response.ok) {
throw new Error(`Failed to fetch families: ${response.statusText}`);
}
const data = await response.json();
return data.families;
}
/**
* Get a single remote model by slug
*/
@@ -135,9 +165,11 @@ export async function fetchTagSizes(slug: string): Promise<RemoteModel> {
/**
* Sync models from ollama.com
* @param fetchDetails - If true, also fetches real capabilities from Ollama for installed models
*/
export async function syncModels(): Promise<SyncResponse> {
const response = await fetch(`${API_BASE}/remote/sync`, {
export async function syncModels(fetchDetails: boolean = true): Promise<SyncResponse> {
const url = fetchDetails ? `${API_BASE}/remote/sync?details=true` : `${API_BASE}/remote/sync`;
const response = await fetch(url, {
method: 'POST'
});

View File

@@ -330,6 +330,7 @@ class SyncManager {
updatedAt: new Date(backendChat.updated_at).getTime(),
isPinned: backendChat.pinned,
isArchived: backendChat.archived,
systemPromptId: backendChat.system_prompt_id ?? null,
messageCount: backendChat.messages?.length ?? existing?.messageCount ?? 0,
syncVersion: backendChat.sync_version
};
@@ -378,6 +379,7 @@ class SyncManager {
model: conv.model,
pinned: conv.isPinned,
archived: conv.isArchived,
system_prompt_id: conv.systemPromptId ?? undefined,
created_at: new Date(conv.createdAt).toISOString(),
updated_at: new Date(conv.updatedAt).toISOString(),
sync_version: conv.syncVersion ?? 1

View File

@@ -9,6 +9,7 @@ export interface BackendChat {
model: string;
pinned: boolean;
archived: boolean;
system_prompt_id?: string | null;
created_at: string;
updated_at: string;
sync_version: number;

View File

@@ -0,0 +1,270 @@
<script lang="ts">
/**
* AttachmentDisplay - Shows attached files on messages
* Displays compact badges with file info, supports image preview and download
*/
import { getAttachmentMetaByIds, getAttachment, createDownloadUrl } from '$lib/storage';
import type { AttachmentMeta, StoredAttachment } from '$lib/storage';
interface Props {
/** Array of attachment IDs to display */
attachmentIds: string[];
}
const { attachmentIds }: Props = $props();
// Attachment metadata loaded from IndexedDB
let attachments = $state<AttachmentMeta[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// Image preview modal state
let previewImage = $state<{ url: string; filename: string } | null>(null);
let downloadingId = $state<string | null>(null);
// Load attachments when IDs change
$effect(() => {
if (attachmentIds.length > 0) {
loadAttachments();
} else {
attachments = [];
loading = false;
}
});
async function loadAttachments(): Promise<void> {
loading = true;
error = null;
const result = await getAttachmentMetaByIds(attachmentIds);
if (result.success) {
attachments = result.data;
} else {
error = result.error;
}
loading = false;
}
/**
* Get icon for attachment type
*/
function getTypeIcon(type: string): string {
switch (type) {
case 'image':
return 'M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z';
case 'pdf':
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
case 'text':
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
default:
return 'M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13';
}
}
/**
* Get color class for attachment type
*/
function getTypeColor(type: string): string {
switch (type) {
case 'image':
return 'text-violet-400 bg-violet-500/20';
case 'pdf':
return 'text-red-400 bg-red-500/20';
case 'text':
return 'text-emerald-400 bg-emerald-500/20';
default:
return 'text-slate-400 bg-slate-500/20';
}
}
/**
* Format file size for display
*/
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Handle attachment click - preview for images, download for others
*/
async function handleClick(attachment: AttachmentMeta): Promise<void> {
if (attachment.type === 'image') {
await showImagePreview(attachment);
} else {
await downloadAttachment(attachment);
}
}
/**
* Show image preview modal
*/
async function showImagePreview(attachment: AttachmentMeta): Promise<void> {
const result = await getAttachment(attachment.id);
if (result.success && result.data) {
const url = createDownloadUrl(result.data);
previewImage = { url, filename: attachment.filename };
}
}
/**
* Close image preview and revoke URL
*/
function closePreview(): void {
if (previewImage) {
URL.revokeObjectURL(previewImage.url);
previewImage = null;
}
}
/**
* Download attachment
*/
async function downloadAttachment(attachment: AttachmentMeta): Promise<void> {
downloadingId = attachment.id;
try {
const result = await getAttachment(attachment.id);
if (result.success && result.data) {
const url = createDownloadUrl(result.data);
const a = document.createElement('a');
a.href = url;
a.download = attachment.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} finally {
downloadingId = null;
}
}
/**
* Handle keyboard events for attachment buttons
*/
function handleKeydown(event: KeyboardEvent, attachment: AttachmentMeta): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick(attachment);
}
}
</script>
{#if loading}
<div class="flex items-center gap-2 text-sm text-theme-muted">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Loading attachments...</span>
</div>
{:else if error}
<div class="text-sm text-red-400">
Failed to load attachments: {error}
</div>
{:else if attachments.length > 0}
<div class="mt-2 flex flex-wrap gap-2">
{#each attachments as attachment (attachment.id)}
<button
type="button"
onclick={() => handleClick(attachment)}
onkeydown={(e) => handleKeydown(e, attachment)}
class="group flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors hover:brightness-110 {getTypeColor(attachment.type)}"
title={attachment.type === 'image' ? 'Click to preview' : 'Click to download'}
>
<!-- Type icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4 flex-shrink-0"
>
<path stroke-linecap="round" stroke-linejoin="round" d={getTypeIcon(attachment.type)} />
</svg>
<!-- Filename (truncated) -->
<span class="max-w-[150px] truncate">
{attachment.filename}
</span>
<!-- Size -->
<span class="text-xs opacity-70">
{formatSize(attachment.size)}
</span>
<!-- Analyzed badge -->
{#if attachment.analyzed}
<span class="rounded bg-amber-500/30 px-1 py-0.5 text-[10px] font-medium text-amber-300" title="Content was summarized by AI">
analyzed
</span>
{/if}
<!-- Download/loading indicator -->
{#if downloadingId === attachment.id}
<svg class="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if attachment.type !== 'image'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-100"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{/if}
</button>
{/each}
</div>
{/if}
<!-- Image preview modal -->
{#if previewImage}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Image preview"
>
<button
type="button"
onclick={closePreview}
class="absolute inset-0"
aria-label="Close preview"
></button>
<div class="relative max-h-[90vh] max-w-[90vw]">
<img
src={previewImage.url}
alt={previewImage.filename}
class="max-h-[85vh] max-w-full rounded-lg object-contain"
/>
<!-- Close button -->
<button
type="button"
onclick={closePreview}
class="absolute -right-3 -top-3 flex h-8 w-8 items-center justify-center rounded-full bg-slate-800 text-white shadow-lg hover:bg-slate-700"
aria-label="Close preview"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Filename -->
<div class="mt-2 text-center text-sm text-white/70">
{previewImage.filename}
</div>
</div>
</div>
{/if}

View File

@@ -7,13 +7,14 @@
import { modelsState } from '$lib/stores';
import type { FileAttachment } from '$lib/types/attachment.js';
import { formatAttachmentsForMessage, processFile } from '$lib/utils/file-processor.js';
import { processFile } from '$lib/utils/file-processor.js';
import { isImageMimeType } from '$lib/types/attachment.js';
import { estimateMessageTokens, formatTokenCount } from '$lib/memory/tokenizer';
import FileUpload from './FileUpload.svelte';
interface Props {
onSend?: (content: string, images?: string[]) => void;
/** Callback when message is sent. Includes content, images for vision models, and pending file attachments to persist */
onSend?: (content: string, images?: string[], pendingAttachments?: FileAttachment[]) => void;
onStop?: () => void;
isStreaming?: boolean;
disabled?: boolean;
@@ -47,6 +48,7 @@
// Drag overlay state
let isDragOver = $state(false);
let dragCounter = 0; // Track enter/leave for nested elements
let dragResetTimeout: ReturnType<typeof setTimeout> | null = null;
// Derived state
const hasContent = $derived(
@@ -98,20 +100,14 @@
/**
* Send the message
* Passes raw attachments to ChatWindow which handles analysis
*/
function handleSend(): void {
if (!canSend) return;
let content = inputValue.trim();
const content = inputValue.trim();
const images = pendingImages.length > 0 ? [...pendingImages] : undefined;
// Prepend file attachments content to the message
if (pendingAttachments.length > 0) {
const attachmentContent = formatAttachmentsForMessage(pendingAttachments);
if (attachmentContent) {
content = attachmentContent + (content ? '\n\n' + content : '');
}
}
const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined;
// Clear input, images, and attachments
inputValue = '';
@@ -123,7 +119,8 @@
textareaElement.style.height = 'auto';
}
onSend?.(content, images);
// Pass raw content + attachments to parent (ChatWindow handles analysis)
onSend?.(content, images, attachments);
// Keep focus on input after sending
requestAnimationFrame(() => focusInput());
@@ -172,19 +169,44 @@
$effect(() => {
if (disabled) return;
// Helper to reset drag state with optional delay
function resetDragState(): void {
dragCounter = 0;
isDragOver = false;
if (dragResetTimeout) {
clearTimeout(dragResetTimeout);
dragResetTimeout = null;
}
}
// Schedule a fallback reset in case events get lost
function scheduleFallbackReset(): void {
if (dragResetTimeout) {
clearTimeout(dragResetTimeout);
}
// Reset after 100ms of no drag activity
dragResetTimeout = setTimeout(() => {
if (isDragOver) {
resetDragState();
}
}, 100);
}
function onDragEnter(event: DragEvent): void {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
dragCounter++;
isDragOver = true;
scheduleFallbackReset();
}
function onDragLeave(event: DragEvent): void {
event.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
isDragOver = false;
resetDragState();
} else {
scheduleFallbackReset();
}
}
@@ -194,12 +216,13 @@
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
// Keep resetting the timeout while actively dragging
scheduleFallbackReset();
}
function onDrop(event: DragEvent): void {
event.preventDefault();
isDragOver = false;
dragCounter = 0;
resetDragState();
if (!event.dataTransfer?.files.length) return;
const files = Array.from(event.dataTransfer.files);
@@ -216,6 +239,9 @@
document.removeEventListener('dragleave', onDragLeave);
document.removeEventListener('dragover', onDragOver);
document.removeEventListener('drop', onDrop);
if (dragResetTimeout) {
clearTimeout(dragResetTimeout);
}
};
});
@@ -252,8 +278,13 @@
}
}
// Process other files
// Process other files (limit to 5 total attachments)
const MAX_ATTACHMENTS = 5;
for (const file of otherFiles) {
if (pendingAttachments.length >= MAX_ATTACHMENTS) {
console.warn(`Maximum ${MAX_ATTACHMENTS} files reached, skipping remaining files`);
break;
}
const result = await processFile(file);
if (result.success) {
pendingAttachments = [...pendingAttachments, result.attachment];
@@ -360,7 +391,7 @@
></textarea>
<!-- Action buttons -->
<div class="flex items-center">
<div class="flex items-center gap-2">
{#if showStopButton}
<!-- Stop button -->
<button

View File

@@ -5,10 +5,14 @@
*/
import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState } from '$lib/stores';
import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js';
import { serverConversationsState } from '$lib/stores/server-conversations.svelte';
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
import { ollamaClient } from '$lib/ollama';
import { addMessage as addStoredMessage, updateConversation, createConversation as createStoredConversation } from '$lib/storage';
import { addMessage as addStoredMessage, updateConversation, createConversation as createStoredConversation, saveAttachments } from '$lib/storage';
import type { FileAttachment } from '$lib/types/attachment.js';
import { fileAnalyzer, analyzeFilesInBatches, formatAnalyzedAttachment, type AnalysisResult } from '$lib/services/fileAnalyzer.js';
import { attachmentService } from '$lib/services/attachmentService.js';
import {
contextManager,
generateSummary,
@@ -22,7 +26,7 @@
import { runToolCalls, formatToolResultsForChat, getFunctionModel, USE_FUNCTION_MODEL } from '$lib/tools';
import type { OllamaMessage, OllamaToolCall, OllamaToolDefinition } from '$lib/ollama';
import type { Conversation } from '$lib/types/conversation';
import MessageList from './MessageList.svelte';
import VirtualMessageList from './VirtualMessageList.svelte';
import ChatInput from './ChatInput.svelte';
import EmptyState from './EmptyState.svelte';
import ContextUsageBar from './ContextUsageBar.svelte';
@@ -41,7 +45,7 @@
*/
interface Props {
mode?: 'new' | 'conversation';
onFirstMessage?: (content: string, images?: string[]) => Promise<void>;
onFirstMessage?: (content: string, images?: string[], attachments?: FileAttachment[]) => Promise<void>;
conversation?: Conversation | null;
/** Bindable prop for thinking mode - synced with parent in 'new' mode */
thinkingEnabled?: boolean;
@@ -62,11 +66,15 @@
// Context full modal state
let showContextFullModal = $state(false);
let pendingMessage: { content: string; images?: string[] } | null = $state(null);
let pendingMessage: { content: string; images?: string[]; attachments?: FileAttachment[] } | null = $state(null);
// Tool execution state
let isExecutingTools = $state(false);
// File analysis state
let isAnalyzingFiles = $state(false);
let analyzingFileNames = $state<string[]>([]);
// RAG (Retrieval-Augmented Generation) state
let ragEnabled = $state(true);
let hasKnowledgeBase = $state(false);
@@ -182,6 +190,15 @@
}
});
// Sync custom context limit with settings
$effect(() => {
if (settingsState.useCustomParameters) {
contextManager.setCustomContextLimit(settingsState.num_ctx);
} else {
contextManager.setCustomContextLimit(null);
}
});
// Update context manager when messages change
$effect(() => {
contextManager.updateMessages(chatState.visibleMessages);
@@ -207,13 +224,38 @@
/**
* Convert chat state messages to Ollama API format
* Uses messagesForContext to exclude summarized originals but include summaries
* Now includes attachment content loaded from IndexedDB
*/
function getMessagesForApi(): OllamaMessage[] {
return chatState.messagesForContext.map((node) => ({
role: node.message.role as OllamaMessage['role'],
content: node.message.content,
images: node.message.images
}));
async function getMessagesForApi(): Promise<OllamaMessage[]> {
const messages: OllamaMessage[] = [];
for (const node of chatState.messagesForContext) {
let content = node.message.content;
let images = node.message.images;
// Load attachment content if present
if (node.message.attachmentIds && node.message.attachmentIds.length > 0) {
// Load text content from attachments
const attachmentContent = await attachmentService.buildOllamaContent(node.message.attachmentIds);
if (attachmentContent) {
content = content + '\n\n' + attachmentContent;
}
// Load image base64 from attachments
const attachmentImages = await attachmentService.buildOllamaImages(node.message.attachmentIds);
if (attachmentImages.length > 0) {
images = [...(images || []), ...attachmentImages];
}
}
messages.push({
role: node.message.role as OllamaMessage['role'],
content,
images
});
}
return messages;
}
/**
@@ -262,6 +304,49 @@
}
}
/**
* Handle automatic compaction of older messages
* Called after assistant response completes when auto-compact is enabled
*/
async function handleAutoCompact(): Promise<void> {
// Check if auto-compact should be triggered
if (!contextManager.shouldAutoCompact()) return;
const selectedModel = modelsState.selectedId;
if (!selectedModel || isSummarizing) return;
const messages = chatState.visibleMessages;
const preserveCount = contextManager.getAutoCompactPreserveCount();
const { toSummarize } = selectMessagesForSummarization(messages, 0, preserveCount);
if (toSummarize.length < 2) return;
isSummarizing = true;
try {
// Generate summary using the LLM
const summary = await generateSummary(toSummarize, selectedModel);
// Mark original messages as summarized
const messageIdsToSummarize = toSummarize.map((node) => node.id);
chatState.markAsSummarized(messageIdsToSummarize);
// Insert the summary message (inline indicator will be shown by MessageList)
chatState.insertSummaryMessage(summary);
// Force context recalculation
contextManager.updateMessages(chatState.visibleMessages, true);
// Subtle notification for auto-compact (inline indicator is the primary feedback)
console.log(`[Auto-compact] Summarized ${toSummarize.length} messages`);
} catch (error) {
console.error('[Auto-compact] Failed:', error);
// Silent failure for auto-compact - don't interrupt user flow
} finally {
isSummarizing = false;
}
}
// =========================================================================
// Context Full Modal Handlers
// =========================================================================
@@ -275,9 +360,9 @@
// After summarization, try to send the pending message
if (pendingMessage && contextManager.contextUsage.percentage < 100) {
const { content, images } = pendingMessage;
const { content, images, attachments } = pendingMessage;
pendingMessage = null;
await handleSendMessage(content, images);
await handleSendMessage(content, images, attachments);
} else if (pendingMessage) {
// Still full after summarization - show toast
toastState.warning('Context still full after summarization. Try starting a new chat.');
@@ -304,10 +389,10 @@
// Try to send the message anyway (may fail or get truncated)
if (pendingMessage) {
const { content, images } = pendingMessage;
const { content, images, attachments } = pendingMessage;
pendingMessage = null;
// Bypass the context check by calling the inner logic directly
await sendMessageInternal(content, images);
await sendMessageInternal(content, images, attachments);
}
}
@@ -319,7 +404,7 @@
/**
* Send a message - checks context and may show modal
*/
async function handleSendMessage(content: string, images?: string[]): Promise<void> {
async function handleSendMessage(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
const selectedModel = modelsState.selectedId;
if (!selectedModel) {
@@ -330,24 +415,24 @@
// Check if context is full (100%+)
if (contextManager.contextUsage.percentage >= 100) {
// Store pending message and show modal
pendingMessage = { content, images };
pendingMessage = { content, images, attachments };
showContextFullModal = true;
return;
}
await sendMessageInternal(content, images);
await sendMessageInternal(content, images, attachments);
}
/**
* Internal: Send message and stream response (bypasses context check)
*/
async function sendMessageInternal(content: string, images?: string[]): Promise<void> {
async function sendMessageInternal(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
const selectedModel = modelsState.selectedId;
if (!selectedModel) return;
// In 'new' mode with no messages yet, create conversation first
if (mode === 'new' && !hasMessages && onFirstMessage) {
await onFirstMessage(content, images);
await onFirstMessage(content, images, attachments);
return;
}
@@ -370,36 +455,161 @@
}
}
// Add user message to tree
// Collect attachment IDs if we have attachments to save
let attachmentIds: string[] | undefined;
if (attachments && attachments.length > 0) {
attachmentIds = attachments.map(a => a.id);
}
// Add user message to tree (including attachmentIds for display)
const userMessageId = chatState.addMessage({
role: 'user',
content,
images
images,
attachmentIds
});
// Persist user message to IndexedDB with the SAME ID as chatState
// Persist user message and attachments to IndexedDB
if (conversationId) {
const parentId = chatState.activePath.length >= 2
? chatState.activePath[chatState.activePath.length - 2]
: null;
await addStoredMessage(conversationId, { role: 'user', content, images }, parentId, userMessageId);
// Save attachments first (they need the messageId)
if (attachments && attachments.length > 0) {
// Use original File objects for storage (preserves binary data)
const files = attachments.map((a) => {
if (a.originalFile) {
return a.originalFile;
}
// Fallback: reconstruct from processed data (shouldn't be needed normally)
if (a.base64Data) {
const binary = atob(a.base64Data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new File([bytes], a.filename, { type: a.mimeType });
}
// For text/PDF without original, create placeholder (download won't work)
console.warn(`No original file for attachment ${a.filename}, download may not work`);
return new File([a.textContent || ''], a.filename, { type: a.mimeType });
});
const saveResult = await saveAttachments(userMessageId, files, attachments);
if (!saveResult.success) {
console.error('Failed to save attachments:', saveResult.error);
}
}
// Save message with attachmentIds
await addStoredMessage(conversationId, { role: 'user', content, images, attachmentIds }, parentId, userMessageId);
}
// Stream assistant message with optional tool support
await streamAssistantResponse(selectedModel, userMessageId, conversationId);
// Process attachments if any
let contentForOllama = content;
let processingMessageId: string | undefined;
if (attachments && attachments.length > 0) {
// Show processing indicator - this message will become the assistant response
isAnalyzingFiles = true;
analyzingFileNames = attachments.map(a => a.filename);
processingMessageId = chatState.startStreaming();
const fileCount = attachments.length;
const fileLabel = fileCount === 1 ? 'file' : 'files';
chatState.setStreamContent(`Processing ${fileCount} ${fileLabel}...`);
try {
// Check if any files need actual LLM analysis
// Force analysis when >3 files to prevent context overflow (max 5 files allowed)
const forceAnalysis = attachments.length > 3;
const filesToAnalyze = forceAnalysis
? attachments.filter(a => a.textContent && a.textContent.length > 2000)
: attachments.filter(a => fileAnalyzer.shouldAnalyze(a));
if (filesToAnalyze.length > 0) {
// Update indicator to show analysis
chatState.setStreamContent(`Analyzing ${filesToAnalyze.length} ${filesToAnalyze.length === 1 ? 'file' : 'files'}...`);
const analysisResults = await analyzeFilesInBatches(filesToAnalyze, selectedModel, 3);
// Update attachments with results
filesToAnalyze.forEach((file) => {
const result = analysisResults.get(file.id);
if (result) {
file.analyzed = result.analyzed;
file.summary = result.summary;
}
});
// Build formatted content with file summaries
const formattedParts: string[] = [content];
for (const attachment of attachments) {
const result = analysisResults.get(attachment.id);
if (result) {
formattedParts.push(formatAnalyzedAttachment(attachment, result));
} else if (attachment.textContent) {
// Non-analyzed text attachment
formattedParts.push(`<file name="${attachment.filename}">\n${attachment.textContent}\n</file>`);
}
}
contentForOllama = formattedParts.join('\n\n');
} else {
// No files need analysis, format with content
const parts: string[] = [content];
for (const a of attachments) {
if (a.textContent) {
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
}
}
contentForOllama = parts.join('\n\n');
}
// Keep "Processing..." visible - LLM streaming will replace it
} catch (error) {
console.error('[ChatWindow] File processing failed:', error);
chatState.setStreamContent('Processing failed, proceeding with original content...');
await new Promise(r => setTimeout(r, 1000));
// Fallback: use original content with raw file text
const parts: string[] = [content];
for (const a of attachments) {
if (a.textContent) {
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
}
}
contentForOllama = parts.join('\n\n');
} finally {
isAnalyzingFiles = false;
analyzingFileNames = [];
}
}
// Stream assistant message (reuse processing message if it exists)
await streamAssistantResponse(selectedModel, userMessageId, conversationId, contentForOllama, processingMessageId);
}
/**
* Stream assistant response with tool call handling and RAG context
* @param contentOverride Optional content to use instead of the last user message content (for formatted attachments)
*/
async function streamAssistantResponse(
model: string,
parentMessageId: string,
conversationId: string | null
conversationId: string | null,
contentOverride?: string,
existingMessageId?: string
): Promise<void> {
const assistantMessageId = chatState.startStreaming();
// Reuse existing message (e.g., from "Processing..." indicator) or create new one
const assistantMessageId = existingMessageId || chatState.startStreaming();
abortController = new AbortController();
// Track if we need to clear the "Processing..." text on first token
let needsClearOnFirstToken = !!existingMessageId;
// Start streaming metrics tracking
streamingMetricsState.startStream();
@@ -407,36 +617,40 @@
let pendingToolCalls: OllamaToolCall[] | null = null;
try {
let messages = getMessagesForApi();
let messages = await getMessagesForApi();
const tools = getToolsForApi();
// Build system prompt from active prompt + thinking + RAG context
const systemParts: string[] = [];
// Wait for prompts to be loaded
await promptsState.ready();
// Priority: per-conversation prompt > new chat prompt > global active prompt > none
let promptContent: string | null = null;
if (conversation?.systemPromptId) {
// Use per-conversation prompt
const conversationPrompt = promptsState.get(conversation.systemPromptId);
if (conversationPrompt) {
promptContent = conversationPrompt.content;
// If we have a content override (formatted attachments), replace the last user message content
if (contentOverride && messages.length > 0) {
const lastUserIndex = messages.findLastIndex(m => m.role === 'user');
if (lastUserIndex !== -1) {
messages = [
...messages.slice(0, lastUserIndex),
{ ...messages[lastUserIndex], content: contentOverride },
...messages.slice(lastUserIndex + 1)
];
}
} else if (newChatPromptId) {
// Use new chat selected prompt (before conversation is created)
const newChatPrompt = promptsState.get(newChatPromptId);
if (newChatPrompt) {
promptContent = newChatPrompt.content;
}
} else if (promptsState.activePrompt) {
// Fall back to global active prompt
promptContent = promptsState.activePrompt.content;
}
if (promptContent) {
systemParts.push(promptContent);
// Build system prompt from resolution service + RAG context
const systemParts: string[] = [];
// Resolve system prompt using priority chain:
// 1. Per-conversation prompt
// 2. New chat selection
// 3. Model-prompt mapping
// 4. Model-embedded prompt (from Modelfile)
// 5. Capability-matched prompt
// 6. Global active prompt
// 7. None
const resolvedPrompt = await resolveSystemPrompt(
model,
conversation?.systemPromptId,
newChatPromptId
);
if (resolvedPrompt.content) {
systemParts.push(resolvedPrompt.content);
}
// RAG: Retrieve relevant context for the last user message
@@ -449,6 +663,9 @@
}
}
// Always add language instruction
systemParts.push('Always respond in the same language the user writes in. Default to English if unclear.');
// Inject combined system message
if (systemParts.length > 0) {
const systemMessage: OllamaMessage = {
@@ -480,6 +697,11 @@
},
{
onThinkingToken: (token) => {
// Clear "Processing..." on first token
if (needsClearOnFirstToken) {
chatState.setStreamContent('');
needsClearOnFirstToken = false;
}
// Accumulate thinking and update the message
if (!streamingThinking) {
// Start the thinking block
@@ -491,6 +713,11 @@
streamingMetricsState.incrementTokens();
},
onToken: (token) => {
// Clear "Processing..." on first token
if (needsClearOnFirstToken) {
chatState.setStreamContent('');
needsClearOnFirstToken = false;
}
// Close thinking block when content starts
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\n\n');
@@ -540,9 +767,15 @@
conversationsState.update(conversationId, {});
}
}
// Check for auto-compact after response completes
await handleAutoCompact();
},
onError: (error) => {
console.error('Streaming error:', error);
// Show error to user instead of leaving "Processing..."
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
@@ -551,6 +784,10 @@
abortController.signal
);
} catch (error) {
console.error('Failed to send message:', error);
// Show error to user
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
toastState.error('Failed to send message. Please try again.');
chatState.finishStreaming();
streamingMetricsState.endStream();
@@ -604,12 +841,16 @@
// The results are stored in toolCalls and displayed by ToolCallDisplay
}
// Persist the assistant message (without flooding text content)
// Persist the assistant message (including toolCalls for reload persistence)
if (conversationId && assistantNode) {
const parentOfAssistant = assistantNode.parentId;
await addStoredMessage(
conversationId,
{ role: 'assistant', content: assistantNode.message.content },
{
role: 'assistant',
content: assistantNode.message.content,
toolCalls: assistantNode.message.toolCalls
},
parentOfAssistant,
assistantMessageId
);
@@ -695,7 +936,7 @@
try {
// Get messages for API - excludes the current empty assistant message being streamed
const messages = getMessagesForApi().filter(m => m.content !== '');
const messages = (await getMessagesForApi()).filter(m => m.content !== '');
const tools = getToolsForApi();
// Use function model for tool routing if enabled and tools are present
@@ -752,6 +993,8 @@
},
onError: (error) => {
console.error('Regenerate error:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
@@ -760,6 +1003,9 @@
abortController.signal
);
} catch (error) {
console.error('Failed to regenerate:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
toastState.error('Failed to regenerate. Please try again.');
chatState.finishStreaming();
streamingMetricsState.endStream();
@@ -813,7 +1059,7 @@
<div class="flex h-full flex-col bg-theme-primary">
{#if hasMessages}
<div class="flex-1 overflow-hidden">
<MessageList
<VirtualMessageList
onRegenerate={handleRegenerate}
onEditMessage={handleEditMessage}
showThinking={thinkingEnabled}
@@ -873,10 +1119,12 @@
<SystemPromptSelector
conversationId={conversation.id}
currentPromptId={conversation.systemPromptId}
modelName={modelsState.selectedId ?? undefined}
/>
{:else if mode === 'new'}
<SystemPromptSelector
currentPromptId={newChatPromptId}
modelName={modelsState.selectedId ?? undefined}
onSelect={(promptId) => (newChatPromptId = promptId)}
/>
{/if}

View File

@@ -1,11 +1,10 @@
<script lang="ts">
/**
* FilePreview.svelte - Preview for attached text/PDF files
* Shows filename, size, and expandable content preview
* Includes remove button on hover
* FilePreview.svelte - Compact preview badge for attached files
* Shows filename, size, and type - no raw content dump
*/
import type { FileAttachment } from '$lib/types/attachment.js';
import { formatFileSize, getFileIcon } from '$lib/utils/file-processor.js';
import { formatFileSize } from '$lib/utils/file-processor.js';
interface Props {
attachment: FileAttachment;
@@ -13,99 +12,124 @@
readonly?: boolean;
}
const { attachment, onRemove, readonly = false }: Props = $props();
const props: Props = $props();
// Expansion state for content preview
let isExpanded = $state(false);
// Truncate preview to first N characters
const PREVIEW_LENGTH = 200;
const hasContent = attachment.textContent && attachment.textContent.length > 0;
const previewText = $derived(
attachment.textContent
? attachment.textContent.slice(0, PREVIEW_LENGTH) +
(attachment.textContent.length > PREVIEW_LENGTH ? '...' : '')
: ''
);
// Derived values to ensure reactivity
const attachment = $derived(props.attachment);
const onRemove = $derived(props.onRemove);
const readonly = $derived(props.readonly ?? false);
function handleRemove() {
onRemove?.(attachment.id);
}
function toggleExpand() {
if (hasContent) {
isExpanded = !isExpanded;
/**
* Get icon path for attachment type
*/
function getIconPath(type: string): string {
switch (type) {
case 'pdf':
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
case 'text':
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
default:
return 'M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13';
}
}
/**
* Get color classes for attachment type
*/
function getTypeStyle(type: string): { icon: string; badge: string; badgeText: string } {
switch (type) {
case 'pdf':
return {
icon: 'text-red-400',
badge: 'bg-red-500/20',
badgeText: 'text-red-300'
};
case 'text':
return {
icon: 'text-emerald-400',
badge: 'bg-emerald-500/20',
badgeText: 'text-emerald-300'
};
default:
return {
icon: 'text-slate-400',
badge: 'bg-slate-500/20',
badgeText: 'text-slate-300'
};
}
}
/**
* Get file extension for display
*/
function getExtension(filename: string): string {
const ext = filename.split('.').pop()?.toUpperCase();
return ext || 'FILE';
}
const style = $derived(getTypeStyle(attachment.type));
const extension = $derived(getExtension(attachment.filename));
</script>
<div
class="group relative flex items-start gap-3 rounded-lg border border-theme/50 bg-theme-secondary/50 p-3 transition-colors hover:bg-theme-secondary"
class="group relative inline-flex items-center gap-2.5 rounded-xl border border-theme/30 bg-theme-secondary/60 px-3 py-2 transition-all hover:border-theme/50 hover:bg-theme-secondary"
>
<!-- File icon -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-theme-tertiary/50 text-lg">
{getFileIcon(attachment.type)}
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg {style.badge}">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4 {style.icon}"
>
<path stroke-linecap="round" stroke-linejoin="round" d={getIconPath(attachment.type)} />
</svg>
</div>
<!-- File info -->
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-theme-secondary" title={attachment.filename}>
{attachment.filename}
</p>
<p class="text-xs text-theme-muted">
{formatFileSize(attachment.size)}
{#if attachment.type === 'pdf'}
<span class="text-theme-muted">·</span>
<span class="text-violet-400">PDF</span>
{/if}
</p>
</div>
<!-- Remove button (only when not readonly) -->
{#if !readonly && onRemove}
<button
type="button"
onclick={handleRemove}
class="shrink-0 rounded p-1 text-theme-muted opacity-0 transition-all hover:bg-red-900/30 hover:text-red-400 group-hover:opacity-100"
aria-label="Remove file"
title="Remove"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
<p class="max-w-[180px] truncate text-sm font-medium text-theme-primary" title={attachment.filename}>
{attachment.filename}
</p>
<div class="flex items-center gap-1.5 text-xs text-theme-muted">
<span>{formatFileSize(attachment.size)}</span>
<span class="opacity-50">·</span>
<span class="rounded px-1 py-0.5 text-[10px] font-medium {style.badge} {style.badgeText}">
{extension}
</span>
{#if attachment.truncated}
<span class="rounded bg-amber-500/20 px-1 py-0.5 text-[10px] font-medium text-amber-300" title="Content was truncated due to size">
truncated
</span>
{/if}
</div>
<!-- Content preview (expandable) -->
{#if hasContent}
<button
type="button"
onclick={toggleExpand}
class="mt-2 w-full text-left"
>
<div
class="rounded border border-theme/50 bg-theme-primary/50 p-2 text-xs text-theme-muted transition-colors hover:border-theme-subtle"
>
{#if isExpanded}
<pre class="max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono">{attachment.textContent}</pre>
{:else}
<p class="truncate font-mono">{previewText}</p>
{/if}
<p class="mt-1 text-[10px] text-theme-muted">
{isExpanded ? 'Click to collapse' : 'Click to expand'}
</p>
</div>
</button>
{/if}
</div>
<!-- Remove button -->
{#if !readonly && onRemove}
<button
type="button"
onclick={handleRemove}
class="ml-1 shrink-0 rounded-lg p-1.5 text-theme-muted opacity-0 transition-all hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
aria-label="Remove file"
title="Remove"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
{/if}
</div>

View File

@@ -52,6 +52,9 @@
let errorMessage = $state<string | null>(null);
let fileInputRef: HTMLInputElement | null = $state(null);
// Constants
const MAX_ATTACHMENTS = 5;
// Derived states
const hasAttachments = $derived(attachments.length > 0);
const hasImages = $derived(images.length > 0);
@@ -73,17 +76,21 @@
/**
* Process multiple files
* @param files - Files to process
* @param fromPaste - Whether files came from a paste event (affects image handling)
*/
async function processFiles(files: File[]) {
async function processFiles(files: File[], fromPaste = false) {
isProcessing = true;
errorMessage = null;
const newAttachments: FileAttachment[] = [];
const errors: string[] = [];
const imageFiles: File[] = [];
for (const file of files) {
// Skip images - they're handled by ImageUpload
// Collect images separately
if (isImageMimeType(file.type)) {
imageFiles.push(file);
continue;
}
@@ -95,8 +102,27 @@
}
}
// Handle collected image files
if (imageFiles.length > 0) {
if (supportsVision) {
// Forward to image processing
await processImageFiles(imageFiles);
} else if (!fromPaste) {
// Only show error if user explicitly selected images (not paste)
errors.push(`Images require a vision-capable model (e.g., llava, bakllava)`);
}
}
if (newAttachments.length > 0) {
onAttachmentsChange([...attachments, ...newAttachments]);
const combined = [...attachments, ...newAttachments];
if (combined.length > MAX_ATTACHMENTS) {
const kept = combined.slice(0, MAX_ATTACHMENTS);
const dropped = combined.length - MAX_ATTACHMENTS;
onAttachmentsChange(kept);
errors.push(`Maximum ${MAX_ATTACHMENTS} files allowed. ${dropped} file(s) not added.`);
} else {
onAttachmentsChange(combined);
}
}
if (errors.length > 0) {
@@ -152,7 +178,8 @@
// Handle non-image files
if (files.length > 0) {
processFiles(files);
event.preventDefault(); // Prevent browser from pasting as text
processFiles(files, true);
}
// Handle image files

View File

@@ -48,7 +48,12 @@
* Process and add files to the images array
*/
async function handleFiles(files: FileList | File[]): Promise<void> {
if (!canAddMore) return;
if (!canAddMore) {
// Show error when max reached
errorMessage = `Maximum ${maxImages} images allowed`;
setTimeout(() => { errorMessage = null; }, 3000);
return;
}
const fileArray = Array.from(files);
const validFiles = fileArray.filter(isValidImageType);
@@ -63,13 +68,15 @@
const remainingSlots = maxImages - images.length;
const filesToProcess = validFiles.slice(0, remainingSlots);
// Clear previous error when starting new upload
errorMessage = null;
if (filesToProcess.length < validFiles.length) {
errorMessage = `Only ${remainingSlots} image${remainingSlots === 1 ? '' : 's'} can be added. Maximum: ${maxImages}`;
setTimeout(() => { errorMessage = null; }, 3000);
}
isProcessing = true;
errorMessage = null;
try {
const newImages: string[] = [];

View File

@@ -29,6 +29,9 @@
// Supports both <thinking>...</thinking> and <think>...</think> (qwen3 format)
const THINKING_PATTERN = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/g;
// Pattern to find file attachment blocks (content shown via AttachmentDisplay badges instead)
const FILE_BLOCK_PATTERN = /<file\s+[^>]*>[\s\S]*?<\/file>/g;
// Pattern to detect JSON tool call objects (for models that output them as text)
// Matches: {"name": "...", "arguments": {...}}
const JSON_TOOL_CALL_PATTERN = /^(\s*\{[\s\S]*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[\s\S]*\}\s*\}\s*)$/;
@@ -72,6 +75,13 @@
.trim();
}
/**
* Strip file attachment blocks (content shown via AttachmentDisplay)
*/
function stripFileBlocks(text: string): string {
return text.replace(FILE_BLOCK_PATTERN, '').trim();
}
/**
* Check if text contains tool execution results
*/
@@ -319,7 +329,8 @@
}
// Clean and parse content into parts
const cleanedContent = $derived(cleanToolText(content));
// Strip file blocks (shown via AttachmentDisplay) and tool text
const cleanedContent = $derived(stripFileBlocks(cleanToolText(content)));
const parsedContent = $derived.by(() => {
const result = parseContent(cleanedContent);
// Debug: Log if thinking blocks were found

View File

@@ -10,6 +10,7 @@
import BranchNavigator from './BranchNavigator.svelte';
import StreamingIndicator from './StreamingIndicator.svelte';
import ToolCallDisplay from './ToolCallDisplay.svelte';
import AttachmentDisplay from './AttachmentDisplay.svelte';
interface Props {
node: MessageNode;
@@ -43,6 +44,7 @@
const isSystem = $derived(node.message.role === 'system');
const hasContent = $derived(node.message.content.length > 0);
const hasToolCalls = $derived(node.message.toolCalls && node.message.toolCalls.length > 0);
const hasAttachments = $derived(node.message.attachmentIds && node.message.attachmentIds.length > 0);
// Detect summary messages (compressed conversation history)
const isSummaryMessage = $derived(node.message.isSummary === true);
@@ -228,6 +230,10 @@
/>
{/if}
{#if hasAttachments && node.message.attachmentIds}
<AttachmentDisplay attachmentIds={node.message.attachmentIds} />
{/if}
{#if hasToolCalls && node.message.toolCalls}
<ToolCallDisplay toolCalls={node.message.toolCalls} />
{/if}

View File

@@ -7,6 +7,7 @@
import { chatState } from '$lib/stores';
import type { MessageNode, BranchInfo } from '$lib/types';
import MessageItem from './MessageItem.svelte';
import SummarizationIndicator from './SummarizationIndicator.svelte';
interface Props {
onRegenerate?: () => void;
@@ -208,6 +209,10 @@
>
<div class="mx-auto max-w-4xl px-4 py-6">
{#each chatState.visibleMessages as node, index (node.id)}
<!-- Show summarization indicator before summary messages -->
{#if node.message.isSummary}
<SummarizationIndicator />
{/if}
<MessageItem
{node}
branchInfo={getBranchInfo(node)}

View File

@@ -0,0 +1,17 @@
<script lang="ts">
/**
* SummarizationIndicator - Visual marker showing where conversation was summarized
* Displayed in the message list to indicate context compaction occurred
*/
</script>
<div class="flex items-center gap-3 py-4" role="separator" aria-label="Conversation summarized">
<div class="flex-1 border-t border-dashed border-emerald-500/30"></div>
<div class="flex items-center gap-2 text-xs text-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<span>Earlier messages summarized</span>
</div>
<div class="flex-1 border-t border-dashed border-emerald-500/30"></div>
</div>

View File

@@ -1,59 +1,131 @@
<script lang="ts">
/**
* SystemPromptSelector - Dropdown to select a system prompt for the current conversation
* Allows per-conversation prompt assignment with quick preview
* In 'new' mode (no conversationId), uses onSelect callback for local state management
* Now model-aware: shows embedded prompts and resolved source indicators
*/
import { promptsState, conversationsState, toastState } from '$lib/stores';
import { updateSystemPrompt } from '$lib/storage';
import { modelInfoService } from '$lib/services/model-info-service.js';
import { modelPromptMappingsState } from '$lib/stores/model-prompt-mappings.svelte.js';
import {
resolveSystemPrompt,
getPromptSourceLabel,
type PromptSource
} from '$lib/services/prompt-resolution.js';
interface Props {
conversationId?: string | null;
currentPromptId?: string | null;
/** Model name for model-aware prompt resolution */
modelName?: string;
/** Callback for 'new' mode - called when prompt is selected without a conversation */
onSelect?: (promptId: string | null) => void;
}
let { conversationId = null, currentPromptId = null, onSelect }: Props = $props();
let { conversationId = null, currentPromptId = null, modelName = '', onSelect }: Props = $props();
// UI state
let isOpen = $state(false);
let dropdownElement: HTMLDivElement | null = $state(null);
// Model info state
let hasEmbeddedPrompt = $state(false);
let modelCapabilities = $state<string[]>([]);
let resolvedSource = $state<PromptSource>('none');
let resolvedPromptName = $state<string | undefined>(undefined);
// Available prompts from store
const prompts = $derived(promptsState.prompts);
// Current prompt for this conversation
// Current prompt for this conversation (explicit override)
const currentPrompt = $derived(
currentPromptId ? prompts.find((p) => p.id === currentPromptId) : null
);
// Display text for the button
const buttonText = $derived(currentPrompt?.name ?? 'No system prompt');
// Check if there's a model-prompt mapping
const hasModelMapping = $derived(modelName ? modelPromptMappingsState.hasMapping(modelName) : false);
// Display text for the button
const buttonText = $derived.by(() => {
if (currentPrompt) return currentPrompt.name;
if (resolvedPromptName && resolvedSource !== 'none') return resolvedPromptName;
return 'No system prompt';
});
// Source badge color
const sourceBadgeClass = $derived.by(() => {
switch (resolvedSource) {
case 'per-conversation':
case 'new-chat-selection':
return 'bg-violet-500/20 text-violet-300';
case 'model-mapping':
return 'bg-blue-500/20 text-blue-300';
case 'model-embedded':
return 'bg-amber-500/20 text-amber-300';
case 'capability-match':
return 'bg-emerald-500/20 text-emerald-300';
case 'global-active':
return 'bg-slate-500/20 text-slate-300';
default:
return 'bg-slate-500/20 text-slate-400';
}
});
// Load model info when modelName changes
$effect(() => {
if (modelName) {
loadModelInfo();
}
});
// Resolve prompt when relevant state changes
$effect(() => {
// Depend on these values to trigger re-resolution
const _promptId = currentPromptId;
const _model = modelName;
if (modelName) {
resolveCurrentPrompt();
}
});
async function loadModelInfo(): Promise<void> {
if (!modelName) return;
try {
const info = await modelInfoService.getModelInfo(modelName);
hasEmbeddedPrompt = info.systemPrompt !== null;
modelCapabilities = info.capabilities;
} catch {
hasEmbeddedPrompt = false;
modelCapabilities = [];
}
}
async function resolveCurrentPrompt(): Promise<void> {
if (!modelName) return;
try {
const resolved = await resolveSystemPrompt(modelName, currentPromptId, null);
resolvedSource = resolved.source;
resolvedPromptName = resolved.promptName;
} catch {
resolvedSource = 'none';
resolvedPromptName = undefined;
}
}
/**
* Toggle dropdown
*/
function toggleDropdown(): void {
isOpen = !isOpen;
}
/**
* Close dropdown
*/
function closeDropdown(): void {
isOpen = false;
}
/**
* Handle prompt selection
*/
async function handleSelect(promptId: string | null): Promise<void> {
// In 'new' mode (no conversation), use the callback
if (!conversationId) {
onSelect?.(promptId);
const promptName = promptId ? prompts.find((p) => p.id === promptId)?.name : null;
toastState.success(promptName ? `Using "${promptName}"` : 'System prompt cleared');
toastState.success(promptName ? `Using "${promptName}"` : 'Using model default');
closeDropdown();
return;
}
@@ -61,10 +133,9 @@
// Update in storage for existing conversation
const result = await updateSystemPrompt(conversationId, promptId);
if (result.success) {
// Update in memory
conversationsState.setSystemPrompt(conversationId, promptId);
const promptName = promptId ? prompts.find((p) => p.id === promptId)?.name : null;
toastState.success(promptName ? `Using "${promptName}"` : 'System prompt cleared');
toastState.success(promptName ? `Using "${promptName}"` : 'Using model default');
} else {
toastState.error('Failed to update system prompt');
}
@@ -72,18 +143,12 @@
closeDropdown();
}
/**
* Handle click outside to close
*/
function handleClickOutside(event: MouseEvent): void {
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
closeDropdown();
}
}
/**
* Handle escape key
*/
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && isOpen) {
closeDropdown();
@@ -98,24 +163,40 @@
<button
type="button"
onclick={toggleDropdown}
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors {currentPrompt
? 'bg-violet-500/20 text-violet-300 hover:bg-violet-500/30'
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors {resolvedSource !== 'none'
? sourceBadgeClass
: 'text-theme-muted hover:bg-theme-secondary hover:text-theme-secondary'}"
title={currentPrompt ? `System prompt: ${currentPrompt.name}` : 'Set system prompt'}
title={resolvedPromptName ? `System prompt: ${resolvedPromptName}` : 'Set system prompt'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"
/>
</svg>
<!-- Icon based on source -->
{#if resolvedSource === 'model-embedded'}
<!-- Chip/CPU icon for embedded -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-3.5 w-3.5">
<path d="M14 6H6v8h8V6Z" />
<path fill-rule="evenodd" d="M9.25 3V1.75a.75.75 0 0 1 1.5 0V3h1.5V1.75a.75.75 0 0 1 1.5 0V3h.5A2.75 2.75 0 0 1 17 5.75v.5h1.25a.75.75 0 0 1 0 1.5H17v1.5h1.25a.75.75 0 0 1 0 1.5H17v1.5h1.25a.75.75 0 0 1 0 1.5H17v.5A2.75 2.75 0 0 1 14.25 17h-.5v1.25a.75.75 0 0 1-1.5 0V17h-1.5v1.25a.75.75 0 0 1-1.5 0V17h-1.5v1.25a.75.75 0 0 1-1.5 0V17h-.5A2.75 2.75 0 0 1 3 14.25v-.5H1.75a.75.75 0 0 1 0-1.5H3v-1.5H1.75a.75.75 0 0 1 0-1.5H3v-1.5H1.75a.75.75 0 0 1 0-1.5H3v-.5A2.75 2.75 0 0 1 5.75 3h.5V1.75a.75.75 0 0 1 1.5 0V3h1.5ZM4.5 5.75c0-.69.56-1.25 1.25-1.25h8.5c.69 0 1.25.56 1.25 1.25v8.5c0 .69-.56 1.25-1.25 1.25h-8.5c-.69 0-1.25-.56-1.25-1.25v-8.5Z" clip-rule="evenodd" />
</svg>
{:else}
<!-- Default info icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"
/>
</svg>
{/if}
<span class="max-w-[120px] truncate">{buttonText}</span>
<!-- Source indicator badge -->
{#if resolvedSource !== 'none' && resolvedSource !== 'per-conversation' && resolvedSource !== 'new-chat-selection'}
<span class="rounded px-1 py-0.5 text-[10px] opacity-75">
{getPromptSourceLabel(resolvedSource)}
</span>
{/if}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
@@ -133,9 +214,12 @@
<!-- Dropdown menu -->
{#if isOpen}
<div
class="absolute left-0 top-full z-50 mt-1 w-64 rounded-lg border border-theme bg-theme-secondary py-1 shadow-xl"
class="absolute left-0 top-full z-50 mt-1 w-72 rounded-lg border border-theme bg-theme-secondary py-1 shadow-xl"
>
<!-- No prompt option -->
<!-- Model default section -->
<div class="px-3 py-1.5 text-xs font-medium text-theme-muted uppercase tracking-wide">
Model Default
</div>
<button
type="button"
onclick={() => handleSelect(null)}
@@ -143,7 +227,21 @@
? 'bg-theme-tertiary/50 text-theme-primary'
: 'text-theme-secondary'}"
>
<span class="flex-1">No system prompt</span>
<div class="flex-1">
<div class="flex items-center gap-2">
<span>Use model default</span>
{#if hasEmbeddedPrompt}
<span class="rounded bg-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-300">
Has embedded prompt
</span>
{/if}
</div>
{#if !currentPromptId && resolvedSource !== 'none'}
<div class="mt-0.5 text-xs text-theme-muted">
Currently: {resolvedPromptName ?? 'None'}
</div>
{/if}
</div>
{#if !currentPromptId}
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -162,6 +260,9 @@
{#if prompts.length > 0}
<div class="my-1 border-t border-theme"></div>
<div class="px-3 py-1.5 text-xs font-medium text-theme-muted uppercase tracking-wide">
Your Prompts
</div>
<!-- Available prompts -->
{#each prompts as prompt}
@@ -205,12 +306,26 @@
</button>
{/each}
{:else}
<div class="my-1 border-t border-theme"></div>
<div class="px-3 py-2 text-xs text-theme-muted">
No prompts available. <a href="/prompts" class="text-violet-400 hover:underline"
>Create one</a
>
</div>
{/if}
<!-- Link to model defaults settings -->
<div class="mt-1 border-t border-theme"></div>
<a
href="/settings#model-prompts"
class="flex items-center gap-2 px-3 py-2 text-xs text-theme-muted hover:bg-theme-tertiary hover:text-theme-secondary"
onclick={closeDropdown}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-3.5 w-3.5">
<path fill-rule="evenodd" d="M8.34 1.804A1 1 0 0 1 9.32 1h1.36a1 1 0 0 1 .98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 0 1 1.262.125l.962.962a1 1 0 0 1 .125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.295a1 1 0 0 1 .804.98v1.36a1 1 0 0 1-.804.98l-1.473.295a6.95 6.95 0 0 1-.587 1.416l.834 1.25a1 1 0 0 1-.125 1.262l-.962.962a1 1 0 0 1-1.262.125l-1.25-.834a6.953 6.953 0 0 1-1.416.587l-.295 1.473a1 1 0 0 1-.98.804H9.32a1 1 0 0 1-.98-.804l-.295-1.473a6.957 6.957 0 0 1-1.416-.587l-1.25.834a1 1 0 0 1-1.262-.125l-.962-.962a1 1 0 0 1-.125-1.262l.834-1.25a6.957 6.957 0 0 1-.587-1.416l-1.473-.295A1 1 0 0 1 1 10.68V9.32a1 1 0 0 1 .804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 0 1 .125-1.262l.962-.962A1 1 0 0 1 5.38 3.03l1.25.834a6.957 6.957 0 0 1 1.416-.587l.294-1.473ZM13 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd" />
</svg>
Configure model defaults
</a>
</div>
{/if}
</div>

View File

@@ -0,0 +1,314 @@
<script lang="ts">
/**
* VirtualMessageList - Virtualized message list for large conversations
* Only renders visible messages for performance with long chats
*
* Uses @tanstack/svelte-virtual for virtualization.
* Falls back to regular rendering if virtualization fails.
*/
import { createVirtualizer } from '@tanstack/svelte-virtual';
import { chatState } from '$lib/stores';
import type { MessageNode, BranchInfo } from '$lib/types';
import MessageItem from './MessageItem.svelte';
import SummarizationIndicator from './SummarizationIndicator.svelte';
import { onMount } from 'svelte';
interface Props {
onRegenerate?: () => void;
onEditMessage?: (messageId: string, newContent: string) => void;
showThinking?: boolean;
}
const { onRegenerate, onEditMessage, showThinking = true }: Props = $props();
// Container reference
let scrollContainer: HTMLDivElement | null = $state(null);
// Track if component is mounted (scroll container available)
let isMounted = $state(false);
// Track user scroll state
let userScrolledAway = $state(false);
let autoScrollEnabled = $state(true);
let wasStreaming = false;
// Height cache for measured items (message ID -> height)
const heightCache = new Map<string, number>();
// Default estimated height for messages
const DEFAULT_ITEM_HEIGHT = 150;
// Threshold for scroll detection
const SCROLL_THRESHOLD = 100;
// Get visible messages
const messages = $derived(chatState.visibleMessages);
// Set mounted after component mounts
onMount(() => {
isMounted = true;
});
// Create virtualizer - only functional after mount when scrollContainer exists
const virtualizer = createVirtualizer({
get count() {
return messages.length;
},
getScrollElement: () => scrollContainer,
estimateSize: (index: number) => {
const msg = messages[index];
if (!msg) return DEFAULT_ITEM_HEIGHT;
return heightCache.get(msg.id) ?? DEFAULT_ITEM_HEIGHT;
},
overscan: 5,
});
// Get virtual items with fallback
const virtualItems = $derived.by(() => {
if (!isMounted || !scrollContainer) {
return [];
}
return $virtualizer.getVirtualItems();
});
// Check if we should use fallback (non-virtual) rendering
const useFallback = $derived(
messages.length > 0 && virtualItems.length === 0 && isMounted
);
// Track conversation changes to clear cache
let lastConversationId: string | null = null;
$effect(() => {
const currentId = chatState.conversationId;
if (currentId !== lastConversationId) {
heightCache.clear();
lastConversationId = currentId;
}
});
// Force measure after mount and when scroll container becomes available
$effect(() => {
if (isMounted && scrollContainer && messages.length > 0) {
// Use setTimeout to ensure DOM is fully ready
setTimeout(() => {
$virtualizer.measure();
}, 0);
}
});
// Handle streaming scroll behavior
$effect(() => {
const isStreaming = chatState.isStreaming;
if (isStreaming && !wasStreaming) {
autoScrollEnabled = true;
if (!userScrolledAway && scrollContainer) {
requestAnimationFrame(() => {
if (useFallback) {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight });
} else {
$virtualizer.scrollToIndex(messages.length - 1, { align: 'end' });
}
});
}
}
wasStreaming = isStreaming;
});
// Scroll to bottom during streaming
$effect(() => {
const buffer = chatState.streamBuffer;
const isStreaming = chatState.isStreaming;
if (isStreaming && buffer && autoScrollEnabled && scrollContainer) {
requestAnimationFrame(() => {
if (useFallback) {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight });
} else {
$virtualizer.scrollToIndex(messages.length - 1, { align: 'end' });
}
});
}
});
// Scroll when new messages are added
let previousMessageCount = 0;
$effect(() => {
const currentCount = messages.length;
if (currentCount > previousMessageCount && currentCount > 0 && scrollContainer) {
autoScrollEnabled = true;
userScrolledAway = false;
requestAnimationFrame(() => {
if (useFallback) {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight });
} else {
$virtualizer.scrollToIndex(currentCount - 1, { align: 'end' });
}
});
}
previousMessageCount = currentCount;
});
// Handle scroll events
function handleScroll(): void {
if (!scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
userScrolledAway = scrollHeight - scrollTop - clientHeight > SCROLL_THRESHOLD;
if (userScrolledAway && chatState.isStreaming) {
autoScrollEnabled = false;
}
}
// Scroll to bottom button handler
function scrollToBottom(): void {
if (!scrollContainer) return;
if (useFallback) {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
} else if (messages.length > 0) {
$virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'smooth' });
}
}
// Measure item height after render (for virtualized mode)
function measureItem(node: HTMLElement, index: number) {
const msg = messages[index];
if (!msg) return { destroy: () => {} };
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = entry.contentRect.height;
if (height > 0 && heightCache.get(msg.id) !== height) {
heightCache.set(msg.id, height);
$virtualizer.measure();
}
}
});
resizeObserver.observe(node);
// Initial measurement
const height = node.getBoundingClientRect().height;
if (height > 0) {
heightCache.set(msg.id, height);
}
return {
destroy() {
resizeObserver.disconnect();
}
};
}
// Get branch info for a message
function getBranchInfo(node: MessageNode): BranchInfo | null {
const info = chatState.getBranchInfo(node.id);
if (info && info.totalCount > 1) {
return info;
}
return null;
}
// Handle branch switch
function handleBranchSwitch(messageId: string, direction: 'prev' | 'next'): void {
chatState.switchBranch(messageId, direction);
}
// Check if message is streaming
function isStreamingMessage(node: MessageNode): boolean {
return chatState.isStreaming && chatState.streamingMessageId === node.id;
}
// Check if message is last
function isLastMessage(index: number): boolean {
return index === messages.length - 1;
}
// Show scroll button
const showScrollButton = $derived(userScrolledAway && messages.length > 0);
</script>
<div class="relative h-full">
<div
bind:this={scrollContainer}
onscroll={handleScroll}
class="h-full overflow-y-auto"
role="log"
aria-live="polite"
aria-label="Chat messages"
>
<div class="mx-auto max-w-4xl px-4 py-6">
{#if useFallback}
<!-- Fallback: Regular rendering when virtualization isn't working -->
{#each messages as node, index (node.id)}
{#if node.message.isSummary}
<SummarizationIndicator />
{/if}
<MessageItem
{node}
branchInfo={getBranchInfo(node)}
isStreaming={isStreamingMessage(node)}
isLast={isLastMessage(index)}
{showThinking}
onBranchSwitch={(direction) => handleBranchSwitch(node.id, direction)}
onRegenerate={onRegenerate}
onEdit={(newContent) => onEditMessage?.(node.id, newContent)}
/>
{/each}
{:else}
<!-- Virtualized rendering -->
<div
style="height: {$virtualizer.getTotalSize()}px; width: 100%; position: relative;"
>
{#each virtualItems as virtualRow (virtualRow.key)}
{@const node = messages[virtualRow.index]}
{@const index = virtualRow.index}
{#if node}
<div
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({virtualRow.start}px);"
use:measureItem={index}
>
{#if node.message.isSummary}
<SummarizationIndicator />
{/if}
<MessageItem
{node}
branchInfo={getBranchInfo(node)}
isStreaming={isStreamingMessage(node)}
isLast={isLastMessage(index)}
{showThinking}
onBranchSwitch={(direction) => handleBranchSwitch(node.id, direction)}
onRegenerate={onRegenerate}
onEdit={(newContent) => onEditMessage?.(node.id, newContent)}
/>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
<!-- Scroll to bottom button -->
{#if showScrollButton}
<button
type="button"
onclick={scrollToBottom}
class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-theme-tertiary px-4 py-2 text-sm text-theme-secondary shadow-lg transition-all hover:bg-theme-secondary"
aria-label="Scroll to latest message"
>
<span class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a1 1 0 01-.707-.293l-5-5a1 1 0 011.414-1.414L10 15.586l4.293-4.293a1 1 0 011.414 1.414l-5 5A1 1 0 0110 18z" clip-rule="evenodd" />
</svg>
Jump to latest
</span>
</button>
{/if}
</div>

View File

@@ -24,6 +24,9 @@ export { default as ChatInput } from './ChatInput.svelte';
export { default as ImageUpload } from './ImageUpload.svelte';
export { default as ImagePreview } from './ImagePreview.svelte';
// Attachment display
export { default as AttachmentDisplay } from './AttachmentDisplay.svelte';
// Code display
export { default as CodeBlock } from './CodeBlock.svelte';

View File

@@ -8,13 +8,9 @@
import SidenavHeader from './SidenavHeader.svelte';
import SidenavSearch from './SidenavSearch.svelte';
import ConversationList from './ConversationList.svelte';
import { SettingsModal } from '$lib/components/shared';
// Check if a path is active
const isActive = (path: string) => $page.url.pathname === path;
// Settings modal state
let settingsOpen = $state(false);
</script>
<!-- Overlay for mobile (closes sidenav when clicking outside) -->
@@ -137,11 +133,10 @@
<span>Prompts</span>
</a>
<!-- Settings button -->
<button
type="button"
onclick={() => (settingsOpen = true)}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-hover hover:text-theme-primary"
<!-- Settings link -->
<a
href="/settings"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/settings') ? 'bg-gray-500/20 text-gray-600 dark:bg-gray-700/30 dark:text-gray-300' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -159,10 +154,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span>Settings</span>
</button>
</a>
</div>
</div>
</aside>
<!-- Settings Modal -->
<SettingsModal isOpen={settingsOpen} onClose={() => (settingsOpen = false)} />

View File

@@ -12,6 +12,28 @@
let { model, onSelect }: Props = $props();
/**
* Format a date as relative time (e.g., "2d ago", "3w ago")
*/
function formatRelativeTime(date: string | Date | undefined): string {
if (!date) return '';
const now = Date.now();
const then = new Date(date).getTime();
const diff = now - then;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
if (weeks < 4) return `${weeks}w ago`;
return `${months}mo ago`;
}
// Capability badges config (matches ollama.com capabilities)
const capabilityBadges: Record<string, { icon: string; color: string; label: string }> = {
vision: { icon: '👁', color: 'bg-purple-900/50 text-purple-300', label: 'Vision' },
@@ -92,6 +114,16 @@
<span>{formatContextLength(model.contextLength)}</span>
</div>
{/if}
<!-- Last Updated -->
{#if model.ollamaUpdatedAt}
<div class="flex items-center gap-1" title="Last updated on Ollama: {new Date(model.ollamaUpdatedAt).toLocaleDateString()}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{formatRelativeTime(model.ollamaUpdatedAt)}</span>
</div>
{/if}
</div>
<!-- Size Tags -->

View File

@@ -0,0 +1,309 @@
<script lang="ts">
/**
* ModelEditorDialog - Dialog for creating/editing custom Ollama models
* Supports two modes: create (new model) and edit (update system prompt)
*/
import { modelsState, promptsState } from '$lib/stores';
import { modelCreationState, type ModelEditorMode } from '$lib/stores/model-creation.svelte.js';
import { modelInfoService } from '$lib/services/model-info-service.js';
interface Props {
/** Whether the dialog is open */
isOpen: boolean;
/** Mode: create or edit */
mode: ModelEditorMode;
/** For edit mode: the model being edited */
editingModel?: string;
/** For edit mode: the current system prompt */
currentSystemPrompt?: string;
/** For edit mode: the base model (parent) */
baseModel?: string;
/** Callback when dialog is closed */
onClose: () => void;
}
let { isOpen, mode, editingModel, currentSystemPrompt, baseModel, onClose }: Props = $props();
// Form state
let modelName = $state('');
let selectedBaseModel = $state('');
let systemPrompt = $state('');
let usePromptLibrary = $state(false);
let selectedPromptId = $state<string | null>(null);
// Initialize form when opening
$effect(() => {
if (isOpen) {
if (mode === 'edit' && editingModel) {
modelName = editingModel;
selectedBaseModel = baseModel || '';
systemPrompt = currentSystemPrompt || '';
} else {
modelName = '';
selectedBaseModel = modelsState.chatModels[0]?.name || '';
systemPrompt = '';
}
usePromptLibrary = false;
selectedPromptId = null;
modelCreationState.reset();
}
});
// Get system prompt content (either from textarea or prompt library)
const effectiveSystemPrompt = $derived(
usePromptLibrary && selectedPromptId
? promptsState.get(selectedPromptId)?.content || ''
: systemPrompt
);
// Validation
const isValid = $derived(
modelName.trim().length > 0 &&
(mode === 'edit' || selectedBaseModel.length > 0) &&
effectiveSystemPrompt.trim().length > 0
);
async function handleSubmit(event: Event): Promise<void> {
event.preventDefault();
if (!isValid || modelCreationState.isCreating) return;
const base = mode === 'edit' ? (baseModel || editingModel || '') : selectedBaseModel;
const success = mode === 'edit'
? await modelCreationState.update(modelName, base, effectiveSystemPrompt)
: await modelCreationState.create(modelName, base, effectiveSystemPrompt);
if (success) {
// Close after short delay to show success status
setTimeout(() => {
onClose();
}, 500);
}
}
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget && !modelCreationState.isCreating) {
onClose();
}
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && !modelCreationState.isCreating) {
onClose();
}
}
function handleCancel(): void {
if (modelCreationState.isCreating) {
modelCreationState.cancel();
} else {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="model-editor-title"
>
<!-- Dialog -->
<div class="w-full max-w-lg rounded-xl bg-theme-secondary shadow-xl">
<div class="border-b border-theme px-6 py-4">
<h2 id="model-editor-title" class="text-lg font-semibold text-theme-primary">
{mode === 'edit' ? 'Edit Model System Prompt' : 'Create Custom Model'}
</h2>
{#if mode === 'edit'}
<p class="mt-1 text-xs text-theme-muted">
This will re-create the model with the new system prompt
</p>
{/if}
</div>
{#if modelCreationState.isCreating}
<!-- Progress view -->
<div class="p-6">
<div class="flex flex-col items-center justify-center py-8">
<div class="h-10 w-10 animate-spin rounded-full border-3 border-theme-subtle border-t-violet-500 mb-4"></div>
<p class="text-sm text-theme-secondary mb-2">
{mode === 'edit' ? 'Updating model...' : 'Creating model...'}
</p>
<p class="text-xs text-theme-muted text-center max-w-xs">
{modelCreationState.status}
</p>
</div>
<div class="flex justify-center">
<button
type="button"
onclick={handleCancel}
class="rounded-lg px-4 py-2 text-sm text-red-400 hover:bg-red-900/20"
>
Cancel
</button>
</div>
</div>
{:else if modelCreationState.error}
<!-- Error view -->
<div class="p-6">
<div class="rounded-lg bg-red-900/20 border border-red-500/30 p-4 mb-4">
<p class="text-sm text-red-400">{modelCreationState.error}</p>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => modelCreationState.reset()}
class="rounded-lg px-4 py-2 text-sm text-theme-secondary hover:bg-theme-tertiary"
>
Try Again
</button>
<button
type="button"
onclick={onClose}
class="rounded-lg bg-theme-tertiary px-4 py-2 text-sm text-theme-secondary hover:bg-theme-hover"
>
Close
</button>
</div>
</div>
{:else}
<!-- Form view -->
<form onsubmit={handleSubmit} class="p-6">
<div class="space-y-4">
{#if mode === 'create'}
<!-- Base model selection -->
<div>
<label for="base-model" class="mb-1 block text-sm font-medium text-theme-secondary">
Base Model <span class="text-red-400">*</span>
</label>
<select
id="base-model"
bind:value={selectedBaseModel}
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-primary focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
>
{#each modelsState.chatModels as model (model.name)}
<option value={model.name}>{model.name}</option>
{/each}
</select>
<p class="mt-1 text-xs text-theme-muted">
The model to derive from
</p>
</div>
{/if}
<!-- Model name -->
<div>
<label for="model-name" class="mb-1 block text-sm font-medium text-theme-secondary">
Model Name <span class="text-red-400">*</span>
</label>
<input
id="model-name"
type="text"
bind:value={modelName}
placeholder="e.g., my-coding-assistant"
disabled={mode === 'edit'}
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-primary placeholder-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 disabled:opacity-60"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
{#if mode === 'create'}
<p class="mt-1 text-xs text-theme-muted">
Use lowercase letters, numbers, and hyphens
</p>
{/if}
</div>
<!-- System prompt source toggle -->
<div class="flex items-center gap-4">
<button
type="button"
onclick={() => usePromptLibrary = false}
class="text-sm {!usePromptLibrary ? 'text-violet-400 font-medium' : 'text-theme-muted hover:text-theme-secondary'}"
>
Write prompt
</button>
<span class="text-theme-muted">|</span>
<button
type="button"
onclick={() => usePromptLibrary = true}
class="text-sm {usePromptLibrary ? 'text-violet-400 font-medium' : 'text-theme-muted hover:text-theme-secondary'}"
>
Use from library
</button>
</div>
{#if usePromptLibrary}
<!-- Prompt library selector -->
<div>
<label for="prompt-library" class="mb-1 block text-sm font-medium text-theme-secondary">
Select Prompt <span class="text-red-400">*</span>
</label>
<select
id="prompt-library"
bind:value={selectedPromptId}
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-primary focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value={null}>-- Select a prompt --</option>
{#each promptsState.prompts as prompt (prompt.id)}
<option value={prompt.id}>{prompt.name}</option>
{/each}
</select>
{#if selectedPromptId}
{@const selectedPrompt = promptsState.get(selectedPromptId)}
{#if selectedPrompt}
<div class="mt-2 rounded-lg bg-theme-tertiary p-3 text-xs text-theme-muted max-h-32 overflow-y-auto">
{selectedPrompt.content}
</div>
{/if}
{/if}
</div>
{:else}
<!-- System prompt textarea -->
<div>
<label for="system-prompt" class="mb-1 block text-sm font-medium text-theme-secondary">
System Prompt <span class="text-red-400">*</span>
</label>
<textarea
id="system-prompt"
bind:value={systemPrompt}
placeholder="You are a helpful assistant that..."
rows="6"
class="w-full resize-none rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 font-mono text-sm text-theme-primary placeholder-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
></textarea>
<p class="mt-1 text-xs text-theme-muted">
{systemPrompt.length} characters
</p>
</div>
{/if}
</div>
<!-- Actions -->
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={handleCancel}
class="rounded-lg px-4 py-2 text-sm text-theme-secondary hover:bg-theme-tertiary"
>
Cancel
</button>
<button
type="submit"
disabled={!isValid}
class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{mode === 'edit' ? 'Update Model' : 'Create Model'}
</button>
</div>
</form>
{/if}
</div>
</div>
{/if}

View File

@@ -5,6 +5,7 @@
*/
import { settingsState } from '$lib/stores/settings.svelte';
import { modelsState, type ModelDefaults } from '$lib/stores/models.svelte';
import {
PARAMETER_RANGES,
PARAMETER_LABELS,
@@ -16,6 +17,26 @@
// Parameter keys for iteration
const parameterKeys: (keyof ModelParameters)[] = ['temperature', 'top_k', 'top_p', 'num_ctx'];
// Track model defaults for the selected model
let modelDefaults = $state<ModelDefaults>({});
// Fetch model defaults when panel opens or model changes
$effect(() => {
if (settingsState.isPanelOpen && modelsState.selectedId) {
modelsState.fetchModelDefaults(modelsState.selectedId).then((defaults) => {
modelDefaults = defaults;
});
}
});
/**
* Get the default value for a parameter (from model or hardcoded fallback)
*/
function getDefaultValue(key: keyof ModelParameters): number {
const modelValue = modelDefaults[key];
return modelValue ?? DEFAULT_MODEL_PARAMETERS[key];
}
/**
* Format a parameter value for display
*/
@@ -79,7 +100,7 @@
type="button"
role="switch"
aria-checked={settingsState.useCustomParameters}
onclick={() => settingsState.toggleCustomParameters()}
onclick={() => settingsState.toggleCustomParameters(modelDefaults)}
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-theme-secondary {settingsState.useCustomParameters ? 'bg-sky-600' : 'bg-theme-tertiary'}"
>
<span
@@ -93,7 +114,7 @@
{#each parameterKeys as key}
{@const range = PARAMETER_RANGES[key]}
{@const value = getValue(key)}
{@const isDefault = value === DEFAULT_MODEL_PARAMETERS[key]}
{@const isDefault = value === getDefaultValue(key)}
<div>
<div class="mb-1 flex items-center justify-between">
@@ -132,7 +153,7 @@
<div class="mt-4 flex justify-end">
<button
type="button"
onclick={() => settingsState.resetToDefaults()}
onclick={() => settingsState.resetToDefaults(modelDefaults)}
class="rounded px-2 py-1 text-xs text-theme-muted hover:bg-theme-tertiary hover:text-theme-secondary"
>
Reset to defaults

View File

@@ -1,203 +0,0 @@
<script lang="ts">
/**
* SettingsModal - Application settings dialog
* Handles theme, model defaults, and other preferences
*/
import { modelsState, uiState } from '$lib/stores';
import { getPrimaryModifierDisplay } from '$lib/utils';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const { isOpen, onClose }: Props = $props();
// Settings state (mirrors global state for editing)
let defaultModel = $state<string | null>(null);
// Sync with global state when modal opens
$effect(() => {
if (isOpen) {
defaultModel = modelsState.selectedId;
}
});
/**
* Save settings and close modal
*/
function handleSave(): void {
if (defaultModel) {
modelsState.select(defaultModel);
}
onClose();
}
/**
* Handle backdrop click
*/
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
onClose();
}
}
/**
* Handle escape key
*/
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
const modifierKey = getPrimaryModifierDisplay();
</script>
{#if isOpen}
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<!-- Modal -->
<div
class="w-full max-w-lg rounded-xl bg-theme-secondary shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<h2 id="settings-title" class="text-lg font-semibold text-theme-primary">Settings</h2>
<button
type="button"
onclick={onClose}
class="rounded-lg p-1.5 text-theme-muted hover:bg-theme-tertiary hover:text-theme-secondary"
aria-label="Close settings"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-6 p-6">
<!-- Appearance Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">Appearance</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Dark Mode</p>
<p class="text-xs text-theme-muted">Toggle between light and dark theme</p>
</div>
<button
type="button"
onclick={() => uiState.toggleDarkMode()}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {uiState.darkMode ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={uiState.darkMode}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {uiState.darkMode ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Use System Theme</p>
<p class="text-xs text-theme-muted">Match your OS light/dark preference</p>
</div>
<button
type="button"
onclick={() => uiState.useSystemTheme()}
class="rounded-lg bg-theme-tertiary px-3 py-1.5 text-xs font-medium text-theme-secondary transition-colors hover:bg-theme-tertiary"
>
Sync with System
</button>
</div>
</div>
</section>
<!-- Model Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">Default Model</h3>
<div class="space-y-4">
<div>
<select
bind:value={defaultModel}
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-secondary focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
>
{#each modelsState.chatModels as model}
<option value={model.name}>{model.name}</option>
{/each}
</select>
<p class="mt-1 text-sm text-theme-muted">Model used for new conversations</p>
</div>
</div>
</section>
<!-- Keyboard Shortcuts Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">Keyboard Shortcuts</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between text-theme-secondary">
<span>New Chat</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">{modifierKey}+N</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>Search</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">{modifierKey}+K</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>Toggle Sidebar</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">{modifierKey}+B</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>Send Message</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">Enter</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>New Line</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">Shift+Enter</kbd>
</div>
</div>
</section>
<!-- About Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">About</h3>
<div class="rounded-lg bg-theme-tertiary/50 p-4">
<p class="font-medium text-theme-secondary">Vessel</p>
<p class="mt-1 text-sm text-theme-muted">
A modern interface for local AI with chat, tools, and memory management.
</p>
</div>
</section>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
<button
type="button"
onclick={onClose}
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary hover:bg-theme-tertiary"
>
Cancel
</button>
<button
type="button"
onclick={handleSave}
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500"
>
Save Changes
</button>
</div>
</div>
</div>
{/if}

View File

@@ -10,6 +10,5 @@ export { default as ToastContainer } from './ToastContainer.svelte';
export { default as Skeleton } from './Skeleton.svelte';
export { default as MessageSkeleton } from './MessageSkeleton.svelte';
export { default as ErrorBoundary } from './ErrorBoundary.svelte';
export { default as SettingsModal } from './SettingsModal.svelte';
export { default as ShortcutsModal } from './ShortcutsModal.svelte';
export { default as SearchModal } from './SearchModal.svelte';

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorView, basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorState, Compartment } from '@codemirror/state';
interface Props {
value: string;
language?: 'javascript' | 'python' | 'json';
readonly?: boolean;
placeholder?: string;
minHeight?: string;
onchange?: (value: string) => void;
}
let {
value = $bindable(''),
language = 'javascript',
readonly = false,
placeholder = '',
minHeight = '200px',
onchange
}: Props = $props();
let editorContainer: HTMLDivElement;
let editorView: EditorView | null = null;
const languageCompartment = new Compartment();
const readonlyCompartment = new Compartment();
function getLanguageExtension(lang: string) {
switch (lang) {
case 'python':
return python();
case 'json':
return json();
case 'javascript':
default:
return javascript();
}
}
onMount(() => {
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
if (newValue !== value) {
value = newValue;
onchange?.(newValue);
}
}
});
const state = EditorState.create({
doc: value,
extensions: [
basicSetup,
languageCompartment.of(getLanguageExtension(language)),
readonlyCompartment.of(EditorState.readOnly.of(readonly)),
oneDark,
updateListener,
EditorView.theme({
'&': { minHeight },
'.cm-scroller': { overflow: 'auto' },
'.cm-content': { minHeight },
'&.cm-focused': { outline: 'none' }
}),
placeholder ? EditorView.contentAttributes.of({ 'aria-placeholder': placeholder }) : []
]
});
editorView = new EditorView({
state,
parent: editorContainer
});
});
onDestroy(() => {
editorView?.destroy();
});
// Update editor when value changes externally
$effect(() => {
if (editorView && editorView.state.doc.toString() !== value) {
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: value
}
});
}
});
// Update language when it changes
$effect(() => {
if (editorView) {
editorView.dispatch({
effects: languageCompartment.reconfigure(getLanguageExtension(language))
});
}
});
// Update readonly when it changes
$effect(() => {
if (editorView) {
editorView.dispatch({
effects: readonlyCompartment.reconfigure(EditorState.readOnly.of(readonly))
});
}
});
</script>
<div class="code-editor rounded-md overflow-hidden border border-surface-500/30" bind:this={editorContainer}></div>
<style>
.code-editor :global(.cm-editor) {
font-size: 14px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
}
.code-editor :global(.cm-gutters) {
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
/**
* ToolDocs - Inline documentation panel for tool creation
*/
interface Props {
language: 'javascript' | 'python';
isOpen?: boolean;
onclose?: () => void;
}
const { language, isOpen = false, onclose }: Props = $props();
</script>
{#if isOpen}
<div class="rounded-lg border border-theme-subtle bg-theme-tertiary/50 p-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-theme-primary">
{language === 'javascript' ? 'JavaScript' : 'Python'} Tool Guide
</h4>
{#if onclose}
<button
type="button"
onclick={onclose}
class="text-theme-muted hover:text-theme-primary"
aria-label="Close documentation"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
<div class="space-y-4 text-sm text-theme-secondary">
{#if language === 'javascript'}
<!-- JavaScript Documentation -->
<div>
<h5 class="font-medium text-theme-primary mb-1">Arguments</h5>
<p>Access parameters via the <code class="bg-theme-primary/30 px-1 rounded text-xs">args</code> object:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>const name = args.name;
const count = args.count || 10;</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Return Value</h5>
<p>Return any JSON-serializable value:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>return {'{'}
success: true,
data: result
{'}'};</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Async/Await</h5>
<p>Full async support - use await for API calls:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>const res = await fetch(args.url);
const data = await res.json();
return data;</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Error Handling</h5>
<p>Throw errors to signal failures:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>if (!args.required_param) {'{'}
throw new Error('Missing required param');
{'}'}</code></pre>
</div>
{:else}
<!-- Python Documentation -->
<div>
<h5 class="font-medium text-theme-primary mb-1">Arguments</h5>
<p>Access parameters via the <code class="bg-theme-primary/30 px-1 rounded text-xs">args</code> dict:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>name = args.get('name')
count = args.get('count', 10)</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Return Value</h5>
<p>Print JSON to stdout (import json first):</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>import json
result = {'{'}'success': True, 'data': data{'}'}
print(json.dumps(result))</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Available Modules</h5>
<p>Python standard library is available:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>import json, math, re
import hashlib, base64
import urllib.request
from collections import Counter</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Error Handling</h5>
<p>Print error JSON or raise exceptions:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>try:
# risky operation
except Exception as e:
print(json.dumps({'{'}'error': str(e){'}'})</code></pre>
</div>
{/if}
<div class="pt-2 border-t border-theme-subtle">
<h5 class="font-medium text-theme-primary mb-1">Tips</h5>
<ul class="list-disc list-inside space-y-1 text-xs text-theme-muted">
<li>Tools run with a 30-second timeout</li>
<li>Large outputs are truncated at 100KB</li>
<li>Network requests are allowed</li>
<li>Use descriptive error messages for debugging</li>
</ul>
</div>
</div>
</div>
{/if}

View File

@@ -5,6 +5,10 @@
import { toolsState } from '$lib/stores';
import type { CustomTool, JSONSchema, JSONSchemaProperty, ToolImplementation } from '$lib/tools';
import { getTemplatesByLanguage, type ToolTemplate } from '$lib/tools';
import CodeEditor from './CodeEditor.svelte';
import ToolDocs from './ToolDocs.svelte';
import ToolTester from './ToolTester.svelte';
interface Props {
isOpen: boolean;
@@ -15,21 +19,62 @@
const { isOpen, editingTool = null, onClose, onSave }: Props = $props();
// Default code templates
const defaultJsCode = `// Arguments are available as \`args\` object
// Return the result
return { message: "Hello from custom tool!" };`;
const defaultPythonCode = `# Arguments available as \`args\` dict
# Print JSON result to stdout
import json
result = {"message": f"Hello, {args.get('name', 'World')}!"}
print(json.dumps(result))`;
// Form state
let name = $state('');
let description = $state('');
let implementation = $state<ToolImplementation>('javascript');
let code = $state('// Arguments are available as `args` object\n// Return the result\nreturn { message: "Hello from custom tool!" };');
let code = $state(defaultJsCode);
let endpoint = $state('');
let httpMethod = $state<'GET' | 'POST'>('POST');
let enabled = $state(true);
// Track previous implementation for code switching
let prevImplementation = $state<ToolImplementation>('javascript');
// Parameters state (simplified - array of parameter definitions)
let parameters = $state<Array<{ name: string; type: string; description: string; required: boolean }>>([]);
// Validation
let errors = $state<Record<string, string>>({});
// UI state
let showDocs = $state(false);
let showTemplates = $state(false);
let showTest = $state(false);
// Get templates for current language
const currentTemplates = $derived(
implementation === 'javascript' || implementation === 'python'
? getTemplatesByLanguage(implementation)
: []
);
function applyTemplate(template: ToolTemplate): void {
name = template.name.toLowerCase().replace(/[^a-z0-9]+/g, '_');
description = template.description;
code = template.code;
// Convert parameters from template
parameters = Object.entries(template.parameters.properties ?? {}).map(([paramName, prop]) => ({
name: paramName,
type: prop.type,
description: prop.description ?? '',
required: template.parameters.required?.includes(paramName) ?? false
}));
showTemplates = false;
}
// Reset form when modal opens or editing tool changes
$effect(() => {
if (isOpen) {
@@ -59,13 +104,32 @@
name = '';
description = '';
implementation = 'javascript';
code = '// Arguments are available as `args` object\n// Return the result\nreturn { message: "Hello from custom tool!" };';
prevImplementation = 'javascript';
code = defaultJsCode;
endpoint = '';
httpMethod = 'POST';
enabled = true;
parameters = [];
}
// Switch to default code when implementation changes (unless editing)
$effect(() => {
if (!editingTool && implementation !== prevImplementation) {
// Only switch code if it's still the default for the previous type
const isDefaultJs = code === defaultJsCode || code.trim() === '';
const isDefaultPy = code === defaultPythonCode;
if (isDefaultJs || isDefaultPy || code.trim() === '') {
if (implementation === 'python') {
code = defaultPythonCode;
} else if (implementation === 'javascript') {
code = defaultJsCode;
}
}
prevImplementation = implementation;
}
});
function addParameter(): void {
parameters = [...parameters, { name: '', type: 'string', description: '', required: false }];
}
@@ -93,8 +157,8 @@
newErrors.description = 'Description is required';
}
if (implementation === 'javascript' && !code.trim()) {
newErrors.code = 'JavaScript code is required';
if ((implementation === 'javascript' || implementation === 'python') && !code.trim()) {
newErrors.code = `${implementation === 'javascript' ? 'JavaScript' : 'Python'} code is required`;
}
if (implementation === 'http' && !endpoint.trim()) {
@@ -144,7 +208,7 @@
description: description.trim(),
parameters: buildParameterSchema(),
implementation,
code: implementation === 'javascript' ? code : undefined,
code: (implementation === 'javascript' || implementation === 'python') ? code : undefined,
endpoint: implementation === 'http' ? endpoint : undefined,
httpMethod: implementation === 'http' ? httpMethod : undefined,
enabled,
@@ -290,7 +354,7 @@
<!-- Implementation Type -->
<div>
<label class="block text-sm font-medium text-theme-secondary">Implementation</label>
<div class="mt-2 flex gap-4">
<div class="mt-2 flex flex-wrap gap-4">
<label class="flex items-center gap-2 text-theme-secondary">
<input
type="radio"
@@ -300,6 +364,15 @@
/>
JavaScript
</label>
<label class="flex items-center gap-2 text-theme-secondary">
<input
type="radio"
bind:group={implementation}
value="python"
class="text-blue-500"
/>
Python
</label>
<label class="flex items-center gap-2 text-theme-secondary">
<input
type="radio"
@@ -312,22 +385,102 @@
</div>
</div>
<!-- JavaScript Code -->
{#if implementation === 'javascript'}
<!-- Code Editor (JavaScript or Python) -->
{#if implementation === 'javascript' || implementation === 'python'}
<div>
<label for="tool-code" class="block text-sm font-medium text-theme-secondary">JavaScript Code</label>
<p class="mt-1 text-xs text-theme-muted">
Arguments are passed as an <code class="bg-theme-tertiary px-1 rounded">args</code> object. Return the result.
<div class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-theme-secondary">
{implementation === 'javascript' ? 'JavaScript' : 'Python'} Code
</label>
<div class="flex items-center gap-2">
<!-- Templates dropdown -->
<div class="relative">
<button
type="button"
onclick={() => showTemplates = !showTemplates}
class="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
Templates
</button>
{#if showTemplates && currentTemplates.length > 0}
<div class="absolute right-0 top-full mt-1 w-64 rounded-lg border border-theme-subtle bg-theme-secondary shadow-lg z-10">
<div class="p-2 space-y-1 max-h-48 overflow-y-auto">
{#each currentTemplates as template (template.id)}
<button
type="button"
onclick={() => applyTemplate(template)}
class="w-full text-left px-3 py-2 rounded hover:bg-theme-tertiary"
>
<div class="text-sm text-theme-primary">{template.name}</div>
<div class="text-xs text-theme-muted truncate">{template.description}</div>
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Docs toggle -->
<button
type="button"
onclick={() => showDocs = !showDocs}
class="flex items-center gap-1 text-xs {showDocs ? 'text-emerald-400' : 'text-theme-muted hover:text-theme-secondary'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
Docs
</button>
</div>
</div>
<p class="text-xs text-theme-muted mb-2">
{#if implementation === 'javascript'}
Arguments are passed as an <code class="bg-theme-tertiary px-1 rounded">args</code> object. Return the result.
{:else}
Arguments are available as <code class="bg-theme-tertiary px-1 rounded">args</code> dict. Print JSON result to stdout.
{/if}
</p>
<textarea
id="tool-code"
bind:value={code}
rows="8"
class="mt-2 w-full rounded-lg border border-theme-subtle bg-theme-primary px-3 py-2 font-mono text-sm text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
></textarea>
<!-- Documentation panel -->
<ToolDocs
language={implementation}
isOpen={showDocs}
onclose={() => showDocs = false}
/>
<div class="mt-2">
<CodeEditor
bind:value={code}
language={implementation === 'python' ? 'python' : 'javascript'}
minHeight="200px"
/>
</div>
{#if errors.code}
<p class="mt-1 text-sm text-red-400">{errors.code}</p>
{/if}
<!-- Test button -->
<button
type="button"
onclick={() => showTest = !showTest}
class="mt-3 flex items-center gap-2 text-sm {showTest ? 'text-emerald-400' : 'text-theme-muted hover:text-theme-secondary'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
{showTest ? 'Hide Test Panel' : 'Test Tool'}
</button>
<!-- Tool tester -->
<ToolTester
{implementation}
{code}
parameters={buildParameterSchema()}
isOpen={showTest}
onclose={() => showTest = false}
/>
</div>
{/if}
@@ -360,6 +513,29 @@
</label>
</div>
</div>
<!-- Test button for HTTP -->
<button
type="button"
onclick={() => showTest = !showTest}
class="flex items-center gap-2 text-sm {showTest ? 'text-emerald-400' : 'text-theme-muted hover:text-theme-secondary'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
{showTest ? 'Hide Test Panel' : 'Test Tool'}
</button>
<!-- Tool tester for HTTP -->
<ToolTester
{implementation}
code=""
{endpoint}
{httpMethod}
parameters={buildParameterSchema()}
isOpen={showTest}
onclose={() => showTest = false}
/>
</div>
{/if}

View File

@@ -0,0 +1,268 @@
<script lang="ts">
/**
* ToolTester - Test panel for running tools with sample inputs
*/
import type { JSONSchema, ToolImplementation } from '$lib/tools';
import CodeEditor from './CodeEditor.svelte';
interface Props {
implementation: ToolImplementation;
code: string;
parameters: JSONSchema;
endpoint?: string;
httpMethod?: 'GET' | 'POST';
isOpen?: boolean;
onclose?: () => void;
}
const { implementation, code, parameters, endpoint = '', httpMethod = 'POST', isOpen = false, onclose }: Props = $props();
let testInput = $state('{}');
let testResult = $state<{ success: boolean; result?: unknown; error?: string } | null>(null);
let isRunning = $state(false);
// Generate example input from parameters
$effect(() => {
if (isOpen && testInput === '{}' && parameters.properties) {
const example: Record<string, unknown> = {};
for (const [name, prop] of Object.entries(parameters.properties)) {
switch (prop.type) {
case 'string':
example[name] = prop.description ? `example_${name}` : '';
break;
case 'number':
example[name] = 0;
break;
case 'boolean':
example[name] = false;
break;
case 'array':
example[name] = [];
break;
case 'object':
example[name] = {};
break;
}
}
if (Object.keys(example).length > 0) {
testInput = JSON.stringify(example, null, 2);
}
}
});
async function runTest(): Promise<void> {
if (isRunning) return;
isRunning = true;
testResult = null;
try {
// Parse the input
let args: Record<string, unknown>;
try {
args = JSON.parse(testInput);
} catch {
testResult = { success: false, error: 'Invalid JSON input' };
isRunning = false;
return;
}
if (implementation === 'javascript') {
// Execute JavaScript directly in browser
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const fn = new AsyncFunction(
'args',
`
"use strict";
${code}
`
);
const result = await fn(args);
testResult = { success: true, result };
} catch (error) {
testResult = {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
} else if (implementation === 'python') {
// Python requires backend execution
try {
const response = await fetch('/api/v1/tools/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
language: 'python',
code,
args,
timeout: 30
})
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const data = await response.json();
if (data.success) {
testResult = { success: true, result: data.result };
} else {
testResult = { success: false, error: data.error || 'Unknown error' };
}
} catch (error) {
testResult = {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
} else if (implementation === 'http') {
// HTTP endpoint execution
if (!endpoint.trim()) {
testResult = { success: false, error: 'HTTP endpoint URL is required' };
isRunning = false;
return;
}
try {
const url = new URL(endpoint);
const options: RequestInit = {
method: httpMethod,
headers: {
'Content-Type': 'application/json'
}
};
if (httpMethod === 'GET') {
// Add args as query parameters
for (const [key, value] of Object.entries(args)) {
url.searchParams.set(key, String(value));
}
} else {
options.body = JSON.stringify(args);
}
const response = await fetch(url.toString(), options);
if (!response.ok) {
testResult = {
success: false,
error: `HTTP ${response.status}: ${response.statusText}`
};
} else {
const contentType = response.headers.get('content-type');
const result = contentType?.includes('application/json')
? await response.json()
: await response.text();
testResult = { success: true, result };
}
} catch (error) {
testResult = {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
} else {
testResult = { success: false, error: 'Unknown implementation type' };
}
} finally {
isRunning = false;
}
}
function formatResult(result: unknown): string {
if (result === undefined) return 'undefined';
if (result === null) return 'null';
try {
return JSON.stringify(result, null, 2);
} catch {
return String(result);
}
}
</script>
{#if isOpen}
<div class="rounded-lg border border-theme-subtle bg-theme-tertiary/50 p-4 mt-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-theme-primary flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Test Tool
</h4>
{#if onclose}
<button
type="button"
onclick={onclose}
class="text-theme-muted hover:text-theme-primary"
aria-label="Close test panel"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
<div class="space-y-4">
<!-- Input -->
<div>
<label class="block text-xs font-medium text-theme-secondary mb-1">Input Arguments (JSON)</label>
<CodeEditor bind:value={testInput} language="json" minHeight="80px" />
</div>
<!-- Run button -->
<button
type="button"
onclick={runTest}
disabled={isRunning || (implementation === 'http' ? !endpoint.trim() : !code.trim())}
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-lg bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isRunning}
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Running...
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Run Test
{/if}
</button>
<!-- Result -->
{#if testResult}
<div>
<label class="block text-xs font-medium text-theme-secondary mb-1">Result</label>
<div
class="rounded-lg p-3 text-sm font-mono overflow-x-auto {testResult.success
? 'bg-emerald-900/30 border border-emerald-500/30'
: 'bg-red-900/30 border border-red-500/30'}"
>
{#if testResult.success}
<div class="flex items-center gap-2 text-emerald-400 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Success
</div>
<pre class="text-theme-primary whitespace-pre-wrap">{formatResult(testResult.result)}</pre>
{:else}
<div class="flex items-center gap-2 text-red-400 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
Error
</div>
<pre class="text-red-300 whitespace-pre-wrap">{testResult.error}</pre>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -2,4 +2,7 @@
* Tools components exports
*/
export { default as CodeEditor } from './CodeEditor.svelte';
export { default as ToolDocs } from './ToolDocs.svelte';
export { default as ToolEditor } from './ToolEditor.svelte';
export { default as ToolTester } from './ToolTester.svelte';

View File

@@ -9,6 +9,7 @@ import type { MessageNode } from '$lib/types/chat.js';
import type { ContextUsage, TokenEstimate, MessageWithTokens } from './types.js';
import { estimateMessageTokens, estimateFormatOverhead, formatTokenCount } from './tokenizer.js';
import { getModelContextLimit, formatContextSize } from './model-limits.js';
import { settingsState } from '$lib/stores/settings.svelte.js';
/** Warning threshold as percentage of context (0.85 = 85%) */
const WARNING_THRESHOLD = 0.85;
@@ -24,8 +25,14 @@ class ContextManager {
/** Current model name */
currentModel = $state<string>('');
/** Maximum context length for current model */
maxTokens = $state<number>(4096);
/** Maximum context length for current model (from model lookup) */
modelMaxTokens = $state<number>(4096);
/** Custom context limit override (from user settings) */
customMaxTokens = $state<number | null>(null);
/** Effective max tokens (custom override or model default) */
maxTokens = $derived(this.customMaxTokens ?? this.modelMaxTokens);
/**
* Cached token estimates for messages (id -> estimate)
@@ -94,7 +101,15 @@ class ContextManager {
*/
setModel(modelName: string): void {
this.currentModel = modelName;
this.maxTokens = getModelContextLimit(modelName);
this.modelMaxTokens = getModelContextLimit(modelName);
}
/**
* Set custom context limit override
* Pass null to clear and use model default
*/
setCustomContextLimit(tokens: number | null): void {
this.customMaxTokens = tokens;
}
/**
@@ -238,6 +253,43 @@ class ContextManager {
this.tokenCache.clear();
this.messagesWithTokens = [];
}
/**
* Check if auto-compact should be triggered
* Returns true if:
* - Auto-compact is enabled in settings
* - Context usage exceeds the configured threshold
* - There are enough messages to summarize
*/
shouldAutoCompact(): boolean {
// Check if auto-compact is enabled
if (!settingsState.autoCompactEnabled) {
return false;
}
// Check context usage against threshold
const threshold = settingsState.autoCompactThreshold;
if (this.contextUsage.percentage < threshold) {
return false;
}
// Check if there are enough messages to summarize
// Need at least preserveCount + 2 messages to have anything to summarize
const preserveCount = settingsState.autoCompactPreserveCount;
const minMessages = preserveCount + 2;
if (this.messagesWithTokens.length < minMessages) {
return false;
}
return true;
}
/**
* Get the number of recent messages to preserve during auto-compact
*/
getAutoCompactPreserveCount(): number {
return settingsState.autoCompactPreserveCount;
}
}
/** Singleton context manager instance */

View File

@@ -79,18 +79,22 @@ export async function generateSummary(
/**
* Determine which messages should be summarized
* Returns indices of messages to summarize (older messages) and messages to keep
* @param messages - All messages in the conversation
* @param targetFreeTokens - Not currently used (preserved for API compatibility)
* @param preserveCount - Number of recent messages to keep (defaults to PRESERVE_RECENT_MESSAGES)
*/
export function selectMessagesForSummarization(
messages: MessageNode[],
targetFreeTokens: number
targetFreeTokens: number,
preserveCount: number = PRESERVE_RECENT_MESSAGES
): { toSummarize: MessageNode[]; toKeep: MessageNode[] } {
if (messages.length <= PRESERVE_RECENT_MESSAGES) {
if (messages.length <= preserveCount) {
return { toSummarize: [], toKeep: messages };
}
// Calculate how many messages to summarize
// Keep the recent ones, summarize the rest
const cutoffIndex = Math.max(0, messages.length - PRESERVE_RECENT_MESSAGES);
const cutoffIndex = Math.max(0, messages.length - preserveCount);
// Filter out system messages from summarization (they should stay)
const toSummarize: MessageNode[] = [];

View File

@@ -20,6 +20,8 @@ import type {
OllamaGenerateRequest,
OllamaGenerateResponse,
OllamaPullProgress,
OllamaCreateRequest,
OllamaCreateProgress,
JsonSchema
} from './types.js';
import {
@@ -214,6 +216,75 @@ export class OllamaClient {
}
}
/**
* Creates a custom model with an embedded system prompt
* POST /api/create with streaming progress
* @param request Create request with model name, base model, and system prompt
* @param onProgress Callback for progress updates
* @param signal Optional abort signal
*/
async createModel(
request: OllamaCreateRequest,
onProgress: (progress: OllamaCreateProgress) => void,
signal?: AbortSignal
): Promise<void> {
const url = `${this.config.baseUrl}/api/create`;
const response = await this.fetchFn(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...request, stream: true }),
signal
});
if (!response.ok) {
throw await createErrorFromResponse(response, '/api/create');
}
if (!response.body) {
throw new Error('No response body for create stream');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete lines
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) continue;
try {
const progress = JSON.parse(line) as OllamaCreateProgress;
// Check for error in response
if ('error' in progress) {
throw new Error((progress as { error: string }).error);
}
onProgress(progress);
} catch (e) {
if (e instanceof Error && e.message !== line) {
throw e;
}
console.warn('[Ollama] Failed to parse create progress:', line);
}
}
}
} finally {
reader.releaseLock();
}
}
// ==========================================================================
// Chat Completion
// ==========================================================================

View File

@@ -0,0 +1,124 @@
/**
* Parser for Ollama Modelfile format
* Extracts system prompts and other directives from modelfile strings
*
* Modelfile format reference: https://github.com/ollama/ollama/blob/main/docs/modelfile.md
*/
/**
* Parse the SYSTEM directive from an Ollama modelfile string.
*
* Handles multiple formats:
* - Multi-line with triple quotes: SYSTEM """...""" or SYSTEM '''...'''
* - Single-line with quotes: SYSTEM "..." or SYSTEM '...'
* - Unquoted single-line: SYSTEM Your prompt here
*
* @param modelfile - Raw modelfile string from Ollama /api/show
* @returns Extracted system prompt or null if none found
*/
export function parseSystemPromptFromModelfile(modelfile: string): string | null {
if (!modelfile) {
return null;
}
// Pattern 1: Multi-line with triple double quotes
// SYSTEM """
// Your multi-line prompt
// """
const tripleDoubleQuoteMatch = modelfile.match(/SYSTEM\s+"""([\s\S]*?)"""/i);
if (tripleDoubleQuoteMatch) {
return tripleDoubleQuoteMatch[1].trim();
}
// Pattern 2: Multi-line with triple single quotes
// SYSTEM '''
// Your multi-line prompt
// '''
const tripleSingleQuoteMatch = modelfile.match(/SYSTEM\s+'''([\s\S]*?)'''/i);
if (tripleSingleQuoteMatch) {
return tripleSingleQuoteMatch[1].trim();
}
// Pattern 3: Single-line with double quotes
// SYSTEM "Your prompt here"
const doubleQuoteMatch = modelfile.match(/SYSTEM\s+"([^"]+)"/i);
if (doubleQuoteMatch) {
return doubleQuoteMatch[1].trim();
}
// Pattern 4: Single-line with single quotes
// SYSTEM 'Your prompt here'
const singleQuoteMatch = modelfile.match(/SYSTEM\s+'([^']+)'/i);
if (singleQuoteMatch) {
return singleQuoteMatch[1].trim();
}
// Pattern 5: Unquoted single-line (less common, stops at newline)
// SYSTEM Your prompt here
const unquotedMatch = modelfile.match(/^SYSTEM\s+([^\n"']+)$/im);
if (unquotedMatch) {
return unquotedMatch[1].trim();
}
return null;
}
/**
* Parse the TEMPLATE directive from a modelfile.
* Templates define how messages are formatted for the model.
*
* @param modelfile - Raw modelfile string
* @returns Template string or null if none found
*/
export function parseTemplateFromModelfile(modelfile: string): string | null {
if (!modelfile) {
return null;
}
// Multi-line template with triple quotes
const tripleQuoteMatch = modelfile.match(/TEMPLATE\s+"""([\s\S]*?)"""/i);
if (tripleQuoteMatch) {
return tripleQuoteMatch[1];
}
// Single-line template
const singleLineMatch = modelfile.match(/TEMPLATE\s+"([^"]+)"/i);
if (singleLineMatch) {
return singleLineMatch[1];
}
return null;
}
/**
* Parse PARAMETER directives from a modelfile.
* Returns a map of parameter names to values.
*
* @param modelfile - Raw modelfile string
* @returns Object with parameter name-value pairs
*/
export function parseParametersFromModelfile(modelfile: string): Record<string, string> {
if (!modelfile) {
return {};
}
const params: Record<string, string> = {};
// Use matchAll to find all PARAMETER lines
const matches = modelfile.matchAll(/^PARAMETER\s+(\w+)\s+(.+)$/gim);
for (const match of matches) {
params[match[1].toLowerCase()] = match[2].trim();
}
return params;
}
/**
* Check if a modelfile has a SYSTEM directive defined.
*
* @param modelfile - Raw modelfile string
* @returns true if SYSTEM directive exists
*/
export function hasSystemPrompt(modelfile: string): boolean {
return parseSystemPromptFromModelfile(modelfile) !== null;
}

View File

@@ -80,6 +80,28 @@ export interface OllamaDeleteRequest {
name: string;
}
// ============================================================================
// Model Create Types
// ============================================================================
/** Request body for POST /api/create */
export interface OllamaCreateRequest {
/** Name for the new model */
model: string;
/** Base model to derive from (e.g., "llama3.2:8b") */
from: string;
/** System prompt to embed in the model */
system?: string;
/** Whether to stream progress (default: true) */
stream?: boolean;
}
/** Progress chunk from POST /api/create streaming response */
export interface OllamaCreateProgress {
/** Status message (e.g., "creating new layer", "writing manifest", "success") */
status: string;
}
// ============================================================================
// Message Types
// ============================================================================

View File

@@ -0,0 +1,433 @@
/**
* Curated prompt templates for the Prompt Browser
*
* These templates are inspired by patterns from popular AI tools and can be
* added to the user's prompt library with one click.
*/
export type PromptCategory = 'coding' | 'writing' | 'analysis' | 'creative' | 'assistant';
export interface PromptTemplate {
id: string;
name: string;
description: string;
content: string;
category: PromptCategory;
targetCapabilities?: string[];
}
export const promptTemplates: PromptTemplate[] = [
// === CODING PROMPTS ===
{
id: 'code-reviewer',
name: 'Code Reviewer',
description: 'Reviews code for bugs, security issues, and best practices',
category: 'coding',
targetCapabilities: ['code'],
content: `You are an expert code reviewer with deep knowledge of software engineering best practices.
When reviewing code:
1. **Correctness**: Identify bugs, logic errors, and edge cases
2. **Security**: Flag potential vulnerabilities (injection, XSS, auth issues, etc.)
3. **Performance**: Spot inefficiencies and suggest optimizations
4. **Readability**: Evaluate naming, structure, and documentation
5. **Best Practices**: Check adherence to language idioms and patterns
Format your review as:
- **Critical Issues**: Must fix before merge
- **Suggestions**: Improvements to consider
- **Positive Notes**: What's done well
Be specific with line references and provide code examples for fixes.`
},
{
id: 'refactoring-expert',
name: 'Refactoring Expert',
description: 'Suggests cleaner implementations and removes code duplication',
category: 'coding',
targetCapabilities: ['code'],
content: `You are a refactoring specialist focused on improving code quality without changing behavior.
Your approach:
1. Identify code smells (duplication, long methods, large classes, etc.)
2. Suggest appropriate design patterns when beneficial
3. Simplify complex conditionals and nested logic
4. Extract reusable functions and components
5. Improve naming for clarity
Guidelines:
- Preserve all existing functionality
- Make incremental, testable changes
- Prefer simplicity over cleverness
- Consider maintainability for future developers
- Explain the "why" behind each refactoring`
},
{
id: 'debug-assistant',
name: 'Debug Assistant',
description: 'Systematic debugging with hypothesis testing',
category: 'coding',
targetCapabilities: ['code'],
content: `You are a systematic debugging expert who helps identify and fix software issues.
Debugging methodology:
1. **Reproduce**: Understand the exact steps to trigger the bug
2. **Isolate**: Narrow down where the problem occurs
3. **Hypothesize**: Form theories about the root cause
4. **Test**: Suggest ways to verify each hypothesis
5. **Fix**: Propose a solution once the cause is confirmed
When debugging:
- Ask clarifying questions about error messages and behavior
- Request relevant code sections and logs
- Consider environmental factors (dependencies, config, state)
- Look for recent changes that might have introduced the bug
- Suggest diagnostic steps (logging, breakpoints, test cases)`
},
{
id: 'api-designer',
name: 'API Designer',
description: 'Designs RESTful and GraphQL APIs with best practices',
category: 'coding',
targetCapabilities: ['code'],
content: `You are an API design expert specializing in creating clean, intuitive, and scalable APIs.
Design principles:
1. **RESTful conventions**: Proper HTTP methods, status codes, resource naming
2. **Consistency**: Uniform patterns across all endpoints
3. **Versioning**: Strategies for backwards compatibility
4. **Authentication**: OAuth, JWT, API keys - when to use each
5. **Documentation**: OpenAPI/Swagger specs, clear examples
Consider:
- Pagination for list endpoints
- Filtering, sorting, and search patterns
- Error response formats
- Rate limiting and quotas
- Batch operations for efficiency
- Idempotency for safe retries`
},
{
id: 'sql-expert',
name: 'SQL Expert',
description: 'Query optimization, schema design, and database migrations',
category: 'coding',
targetCapabilities: ['code'],
content: `You are a database expert specializing in SQL optimization and schema design.
Areas of expertise:
1. **Query Optimization**: Explain execution plans, suggest indexes, rewrite for performance
2. **Schema Design**: Normalization, denormalization trade-offs, relationships
3. **Migrations**: Safe schema changes, zero-downtime deployments
4. **Data Integrity**: Constraints, transactions, isolation levels
When helping:
- Ask about the database system (PostgreSQL, MySQL, SQLite, etc.)
- Consider data volume and query patterns
- Suggest appropriate indexes with reasoning
- Warn about N+1 queries and how to avoid them
- Explain ACID properties when relevant`
},
// === WRITING PROMPTS ===
{
id: 'technical-writer',
name: 'Technical Writer',
description: 'Creates clear documentation, READMEs, and API docs',
category: 'writing',
content: `You are a technical writing expert who creates clear, comprehensive documentation.
Documentation principles:
1. **Audience-aware**: Adjust complexity for the target reader
2. **Task-oriented**: Focus on what users need to accomplish
3. **Scannable**: Use headings, lists, and code blocks effectively
4. **Complete**: Cover setup, usage, examples, and troubleshooting
5. **Maintainable**: Write docs that are easy to update
Document types:
- README files with quick start guides
- API reference documentation
- Architecture decision records (ADRs)
- Runbooks and operational guides
- Tutorial-style walkthroughs
Always include practical examples and avoid jargon without explanation.`
},
{
id: 'copywriter',
name: 'Marketing Copywriter',
description: 'Writes compelling copy for products and marketing',
category: 'writing',
content: `You are a skilled copywriter who creates compelling, conversion-focused content.
Writing approach:
1. **Hook**: Grab attention with a strong opening
2. **Problem**: Identify the pain point or desire
3. **Solution**: Present your offering as the answer
4. **Proof**: Back claims with evidence or social proof
5. **Action**: Clear call-to-action
Adapt tone for:
- Landing pages (benefit-focused, scannable)
- Email campaigns (personal, urgent)
- Social media (concise, engaging)
- Product descriptions (feature-benefit balance)
Focus on benefits over features. Use active voice and concrete language.`
},
// === ANALYSIS PROMPTS ===
{
id: 'ui-ux-advisor',
name: 'UI/UX Advisor',
description: 'Design feedback on usability, accessibility, and aesthetics',
category: 'analysis',
targetCapabilities: ['vision'],
content: `You are a UI/UX design expert who provides actionable feedback on interfaces.
Evaluation criteria:
1. **Usability**: Is it intuitive? Can users accomplish their goals?
2. **Accessibility**: WCAG compliance, screen reader support, color contrast
3. **Visual Hierarchy**: Does the layout guide attention appropriately?
4. **Consistency**: Do patterns repeat predictably?
5. **Responsiveness**: How does it adapt to different screen sizes?
When reviewing:
- Consider the user's mental model and expectations
- Look for cognitive load issues
- Check for clear feedback on user actions
- Evaluate error states and empty states
- Suggest improvements with reasoning
Provide specific, actionable recommendations rather than vague feedback.`
},
{
id: 'security-auditor',
name: 'Security Auditor',
description: 'Identifies vulnerabilities with an OWASP-focused mindset',
category: 'analysis',
targetCapabilities: ['code'],
content: `You are a security expert who identifies vulnerabilities and recommends mitigations.
Focus areas (OWASP Top 10):
1. **Injection**: SQL, NoSQL, OS command, LDAP injection
2. **Broken Authentication**: Session management, credential exposure
3. **Sensitive Data Exposure**: Encryption, data classification
4. **XXE**: XML external entity attacks
5. **Broken Access Control**: Authorization bypasses, IDOR
6. **Security Misconfiguration**: Default credentials, exposed endpoints
7. **XSS**: Reflected, stored, DOM-based cross-site scripting
8. **Insecure Deserialization**: Object injection attacks
9. **Vulnerable Components**: Outdated dependencies
10. **Insufficient Logging**: Audit trails, incident detection
For each finding:
- Explain the vulnerability and its impact
- Provide a proof-of-concept or example
- Recommend specific remediation steps
- Rate severity (Critical, High, Medium, Low)`
},
{
id: 'data-analyst',
name: 'Data Analyst',
description: 'Helps analyze data, create visualizations, and find insights',
category: 'analysis',
content: `You are a data analyst who helps extract insights from data.
Capabilities:
1. **Exploratory Analysis**: Understand data structure, distributions, outliers
2. **Statistical Analysis**: Correlations, hypothesis testing, trends
3. **Visualization**: Chart selection, design best practices
4. **SQL Queries**: Complex aggregations, window functions
5. **Python/Pandas**: Data manipulation and analysis code
Approach:
- Start with understanding the business question
- Examine data quality and completeness
- Suggest appropriate analytical methods
- Present findings with clear visualizations
- Highlight actionable insights
Always explain statistical concepts in accessible terms.`
},
// === CREATIVE PROMPTS ===
{
id: 'creative-brainstormer',
name: 'Creative Brainstormer',
description: 'Generates ideas using lateral thinking techniques',
category: 'creative',
content: `You are a creative ideation partner who helps generate innovative ideas.
Brainstorming techniques:
1. **SCAMPER**: Substitute, Combine, Adapt, Modify, Put to other uses, Eliminate, Reverse
2. **Lateral Thinking**: Challenge assumptions, random entry points
3. **Mind Mapping**: Explore connections and associations
4. **Reverse Brainstorming**: How to cause the problem, then invert
5. **Six Thinking Hats**: Different perspectives on the problem
Guidelines:
- Quantity over quality initially - filter later
- Build on ideas rather than criticizing
- Encourage wild ideas that can be tamed
- Cross-pollinate concepts from different domains
- Question "obvious" solutions
Present ideas in organized categories with brief explanations.`
},
{
id: 'storyteller',
name: 'Storyteller',
description: 'Crafts engaging narratives and creative writing',
category: 'creative',
content: `You are a skilled storyteller who creates engaging narratives.
Story elements:
1. **Character**: Compelling protagonists with clear motivations
2. **Conflict**: Internal and external challenges that drive the plot
3. **Setting**: Vivid world-building that supports the story
4. **Plot**: Beginning hook, rising action, climax, resolution
5. **Theme**: Underlying message or meaning
Writing craft:
- Show, don't tell - use actions and dialogue
- Vary sentence structure and pacing
- Create tension through stakes and uncertainty
- Use sensory details to immerse readers
- End scenes with hooks that pull readers forward
Adapt style to genre: literary, thriller, fantasy, humor, etc.`
},
// === ASSISTANT PROMPTS ===
{
id: 'concise-assistant',
name: 'Concise Assistant',
description: 'Provides minimal, direct responses without fluff',
category: 'assistant',
content: `You are a concise assistant who values brevity and clarity.
Communication style:
- Get straight to the point
- No filler phrases ("Certainly!", "Great question!", "I'd be happy to...")
- Use bullet points for multiple items
- Only elaborate when asked
- Prefer code/examples over explanations when applicable
Format guidelines:
- One-line answers for simple questions
- Short paragraphs for complex topics
- Code blocks without excessive comments
- Tables for comparisons
If clarification is needed, ask specific questions rather than making assumptions.`
},
{
id: 'teacher',
name: 'Patient Teacher',
description: 'Explains concepts with patience and multiple approaches',
category: 'assistant',
content: `You are a patient teacher who adapts explanations to the learner's level.
Teaching approach:
1. **Assess understanding**: Ask what they already know
2. **Build foundations**: Ensure prerequisites are clear
3. **Use analogies**: Connect new concepts to familiar ones
4. **Provide examples**: Concrete illustrations of abstract ideas
5. **Check comprehension**: Ask follow-up questions
Techniques:
- Start simple, add complexity gradually
- Use visual descriptions and diagrams when helpful
- Offer multiple explanations if one doesn't click
- Encourage questions without judgment
- Celebrate progress and understanding
Adapt vocabulary and depth based on the learner's responses.`
},
{
id: 'devils-advocate',
name: "Devil's Advocate",
description: 'Challenges ideas to strengthen arguments and find weaknesses',
category: 'assistant',
content: `You are a constructive devil's advocate who helps strengthen ideas through challenge.
Your role:
1. **Question assumptions**: "What if the opposite were true?"
2. **Find weaknesses**: Identify logical gaps and vulnerabilities
3. **Present counterarguments**: Steel-man opposing viewpoints
4. **Stress test**: Push ideas to their limits
5. **Suggest improvements**: Help address the weaknesses found
Guidelines:
- Be challenging but respectful
- Focus on ideas, not personal criticism
- Acknowledge strengths while probing weaknesses
- Offer specific, actionable critiques
- Help refine rather than simply tear down
Goal: Make ideas stronger through rigorous examination.`
},
{
id: 'meeting-summarizer',
name: 'Meeting Summarizer',
description: 'Distills meetings into action items and key decisions',
category: 'assistant',
content: `You are an expert at summarizing meetings into actionable outputs.
Summary structure:
1. **Key Decisions**: What was decided and by whom
2. **Action Items**: Tasks with owners and deadlines
3. **Discussion Points**: Main topics covered
4. **Open Questions**: Unresolved issues for follow-up
5. **Next Steps**: Immediate actions and future meetings
Format:
- Use bullet points for scannability
- Bold action item owners
- Include context for decisions
- Flag blockers or dependencies
- Keep it under one page
When given meeting notes or transcripts, extract the signal from the noise.`
}
];
/**
* Get all prompt templates
*/
export function getAllPromptTemplates(): PromptTemplate[] {
return promptTemplates;
}
/**
* Get prompt templates by category
*/
export function getPromptTemplatesByCategory(category: PromptCategory): PromptTemplate[] {
return promptTemplates.filter((t) => t.category === category);
}
/**
* Get a prompt template by ID
*/
export function getPromptTemplateById(id: string): PromptTemplate | undefined {
return promptTemplates.find((t) => t.id === id);
}
/**
* Get unique categories from templates
*/
export function getPromptCategories(): PromptCategory[] {
return [...new Set(promptTemplates.map((t) => t.category))];
}
/**
* Category display information
*/
export const categoryInfo: Record<PromptCategory, { label: string; icon: string; color: string }> = {
coding: { label: 'Coding', icon: '💻', color: 'bg-blue-500/20 text-blue-400' },
writing: { label: 'Writing', icon: '✍️', color: 'bg-green-500/20 text-green-400' },
analysis: { label: 'Analysis', icon: '🔍', color: 'bg-purple-500/20 text-purple-400' },
creative: { label: 'Creative', icon: '🎨', color: 'bg-pink-500/20 text-pink-400' },
assistant: { label: 'Assistant', icon: '🤖', color: 'bg-amber-500/20 text-amber-400' }
};

View File

@@ -0,0 +1,372 @@
/**
* Attachment Service
* Coordinates file processing, analysis, and storage for message attachments.
* Acts as the main API for attachment operations in the chat flow.
*/
import { processFile } from '$lib/utils/file-processor.js';
import { fileAnalyzer, type AnalysisResult } from './fileAnalyzer.js';
import {
saveAttachment,
saveAttachments as saveAttachmentsToDb,
getAttachment,
getAttachmentsByIds,
getAttachmentMetaForMessage,
getAttachmentMetaByIds,
getAttachmentBase64,
getAttachmentTextContent,
createDownloadUrl,
deleteAttachment,
deleteAttachmentsByIds,
updateAttachmentAnalysis
} from '$lib/storage/index.js';
import type { StoredAttachment, AttachmentMeta, StorageResult } from '$lib/storage/index.js';
import type { FileAttachment, AttachmentType } from '$lib/types/attachment.js';
/**
* Pending attachment before it's saved to IndexedDB.
* Contains the processed file data and original File object.
*/
export interface PendingAttachment {
file: File;
attachment: FileAttachment;
analysisResult?: AnalysisResult;
}
/**
* Success result of preparing an attachment.
*/
export interface PrepareSuccess {
success: true;
pending: PendingAttachment;
}
/**
* Error result of preparing an attachment.
*/
export interface PrepareError {
success: false;
error: string;
}
/**
* Result of preparing an attachment for sending.
*/
export type PrepareResult = PrepareSuccess | PrepareError;
/**
* Content formatted for inclusion in a message.
*/
export interface FormattedContent {
/** The formatted text content (XML-style tags) */
text: string;
/** Whether any content was analyzed by the sub-agent */
hasAnalyzed: boolean;
/** Total original size of all attachments */
totalSize: number;
}
class AttachmentService {
/**
* Prepare a single file as a pending attachment.
* Processes the file but does not persist to storage.
*/
async prepareAttachment(file: File): Promise<PrepareResult> {
const result = await processFile(file);
if (!result.success) {
return { success: false, error: result.error };
}
return {
success: true,
pending: { file, attachment: result.attachment }
};
}
/**
* Prepare multiple files as pending attachments.
* Returns both successful preparations and any errors.
*/
async prepareAttachments(files: File[]): Promise<{
pending: PendingAttachment[];
errors: string[];
}> {
const pending: PendingAttachment[] = [];
const errors: string[] = [];
for (const file of files) {
const result = await this.prepareAttachment(file);
if (result.success) {
pending.push(result.pending);
} else {
errors.push(`${file.name}: ${result.error}`);
}
}
return { pending, errors };
}
/**
* Analyze pending attachments that exceed size thresholds.
* Spawns sub-agent for large files to summarize content.
*/
async analyzeIfNeeded(
pending: PendingAttachment[],
model: string
): Promise<PendingAttachment[]> {
const analyzed: PendingAttachment[] = [];
for (const item of pending) {
const result = await fileAnalyzer.analyzeIfNeeded(item.attachment, model);
analyzed.push({
...item,
analysisResult: result
});
}
return analyzed;
}
/**
* Save pending attachments to IndexedDB, linking them to a message.
* Returns the attachment IDs for storing in the message.
*/
async savePendingAttachments(
messageId: string,
pending: PendingAttachment[]
): Promise<StorageResult<string[]>> {
const files = pending.map(p => p.file);
const attachments = pending.map(p => p.attachment);
const result = await saveAttachmentsToDb(messageId, files, attachments);
if (result.success) {
// Update analysis status if any were analyzed
for (let i = 0; i < pending.length; i++) {
const item = pending[i];
if (item.analysisResult?.analyzed) {
await updateAttachmentAnalysis(
attachments[i].id,
true,
item.analysisResult.summary
);
}
}
}
return result;
}
/**
* Format pending attachments for inclusion in message content.
* Uses analysis summaries for large files, raw content for small ones.
*/
formatForMessage(pending: PendingAttachment[]): FormattedContent {
let hasAnalyzed = false;
let totalSize = 0;
const parts: string[] = [];
for (const item of pending) {
const { attachment, analysisResult } = item;
totalSize += attachment.size;
// Skip images - they go in the images array, not text content
if (attachment.type === 'image') {
continue;
}
// Skip if no text content to include
if (!attachment.textContent && !analysisResult?.summary) {
continue;
}
const sizeAttr = ` size="${formatFileSize(attachment.size)}"`;
const typeAttr = ` type="${attachment.type}"`;
if (analysisResult && !analysisResult.useOriginal && analysisResult.summary) {
// Use analyzed summary for large files
hasAnalyzed = true;
parts.push(
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr} analyzed="true">\n` +
`${analysisResult.summary}\n` +
`[Full content (${formatFileSize(analysisResult.originalLength)}) stored locally]\n` +
`</file>`
);
} else {
// Use raw content for small files
const content = analysisResult?.content || attachment.textContent || '';
const truncatedAttr = attachment.truncated ? ' truncated="true"' : '';
parts.push(
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr}${truncatedAttr}>\n` +
`${content}\n` +
`</file>`
);
}
}
return {
text: parts.join('\n\n'),
hasAnalyzed,
totalSize
};
}
/**
* Get image base64 data for Ollama from pending attachments.
* Returns array of base64 strings (without data: prefix).
*/
getImagesFromPending(pending: PendingAttachment[]): string[] {
return pending
.filter(p => p.attachment.type === 'image' && p.attachment.base64Data)
.map(p => p.attachment.base64Data!);
}
/**
* Load attachment metadata for display (without binary data).
*/
async getMetaForMessage(messageId: string): Promise<StorageResult<AttachmentMeta[]>> {
return getAttachmentMetaForMessage(messageId);
}
/**
* Load attachment metadata by IDs.
*/
async getMetaByIds(ids: string[]): Promise<StorageResult<AttachmentMeta[]>> {
return getAttachmentMetaByIds(ids);
}
/**
* Load full attachment data by ID.
*/
async getFullAttachment(id: string): Promise<StorageResult<StoredAttachment | null>> {
return getAttachment(id);
}
/**
* Load multiple full attachments by IDs.
*/
async getFullAttachments(ids: string[]): Promise<StorageResult<StoredAttachment[]>> {
return getAttachmentsByIds(ids);
}
/**
* Get base64 data for an image attachment (for Ollama).
*/
async getImageBase64(id: string): Promise<StorageResult<string | null>> {
return getAttachmentBase64(id);
}
/**
* Get text content from an attachment.
*/
async getTextContent(id: string): Promise<StorageResult<string | null>> {
return getAttachmentTextContent(id);
}
/**
* Create a download URL for an attachment.
* Remember to call URL.revokeObjectURL() when done.
*/
async createDownloadUrl(id: string): Promise<string | null> {
const result = await getAttachment(id);
if (!result.success || !result.data) {
return null;
}
return createDownloadUrl(result.data);
}
/**
* Delete a single attachment.
*/
async deleteAttachment(id: string): Promise<StorageResult<void>> {
return deleteAttachment(id);
}
/**
* Delete multiple attachments.
*/
async deleteAttachments(ids: string[]): Promise<StorageResult<void>> {
return deleteAttachmentsByIds(ids);
}
/**
* Build images array for Ollama from stored attachment IDs.
* Loads image base64 data from IndexedDB.
*/
async buildOllamaImages(attachmentIds: string[]): Promise<string[]> {
const images: string[] = [];
for (const id of attachmentIds) {
const result = await getAttachmentBase64(id);
if (result.success && result.data) {
images.push(result.data);
}
}
return images;
}
/**
* Build text content for Ollama from stored attachment IDs.
* Returns formatted XML-style content for non-image attachments.
*/
async buildOllamaContent(attachmentIds: string[]): Promise<string> {
const attachments = await getAttachmentsByIds(attachmentIds);
if (!attachments.success) {
return '';
}
const parts: string[] = [];
for (const attachment of attachments.data) {
// Skip images - they go in images array
if (attachment.type === 'image') {
continue;
}
const content = attachment.textContent || await attachment.data.text().catch(() => null);
if (!content) {
continue;
}
const sizeAttr = ` size="${formatFileSize(attachment.size)}"`;
const typeAttr = ` type="${attachment.type}"`;
const analyzedAttr = attachment.analyzed ? ' analyzed="true"' : '';
const truncatedAttr = attachment.truncated ? ' truncated="true"' : '';
if (attachment.analyzed && attachment.summary) {
// Use stored summary
parts.push(
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr}${analyzedAttr}>\n` +
`${attachment.summary}\n` +
`[Full content stored locally]\n` +
`</file>`
);
} else {
// Use raw content
parts.push(
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr}${truncatedAttr}>\n` +
`${content}\n` +
`</file>`
);
}
}
return parts.join('\n\n');
}
}
// Helpers
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function escapeXmlAttr(str: string): string {
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Singleton export
export const attachmentService = new AttachmentService();

View File

@@ -0,0 +1,407 @@
/**
* File Analyzer Service
*
* Spawns a separate Ollama request to analyze/summarize large files
* before adding them to the main conversation context.
* This keeps the main context clean for conversation while still
* allowing the model to understand file contents.
*/
import { ollamaClient } from '$lib/ollama';
import type { FileAttachment } from '$lib/types/attachment.js';
import { ANALYSIS_THRESHOLD, MAX_EXTRACTED_CONTENT } from '$lib/types/attachment.js';
import { formatFileSize } from '$lib/utils/file-processor.js';
// ============================================================================
// Types
// ============================================================================
export interface AnalysisResult {
/** Whether to use the original content (file was small enough) */
useOriginal: boolean;
/** The content to use in the message (original or summary) */
content: string;
/** Summary generated by the analysis agent (if analyzed) */
summary?: string;
/** Original content size in characters */
originalLength: number;
/** Whether the file was analyzed by the sub-agent */
analyzed: boolean;
/** Error message if analysis failed */
error?: string;
}
export interface FileAnalyzerConfig {
/** Size thresholds for different file types (in bytes) */
thresholds: {
text: number;
pdf: number;
json: number;
};
/** Timeout for analysis request (ms) */
timeout: number;
/** Maximum tokens for analysis response */
maxResponseTokens: number;
}
// ============================================================================
// Default Configuration
// ============================================================================
const DEFAULT_CONFIG: FileAnalyzerConfig = {
thresholds: {
text: 500 * 1024, // 500KB for general text
pdf: 1024 * 1024, // 1MB for PDFs
json: 300 * 1024, // 300KB for JSON (dense data)
},
timeout: 10000, // 10 seconds - fail fast, fall back to truncated
maxResponseTokens: 256, // Keep summaries very concise for speed
};
/** Maximum content to read from file for analysis (50KB) */
const MAX_ANALYSIS_CONTENT = 50 * 1024;
/** If content is within this % of threshold, skip analysis */
const BORDERLINE_THRESHOLD_PERCENT = 0.2; // 20%
// ============================================================================
// Content Reading
// ============================================================================
/**
* Read full content from original file for analysis
* Returns up to MAX_ANALYSIS_CONTENT chars
*/
async function readFullContentForAnalysis(attachment: FileAttachment): Promise<string> {
// If we have the original file, read from it
if (attachment.originalFile) {
try {
const text = await attachment.originalFile.text();
// Limit to max analysis content
if (text.length > MAX_ANALYSIS_CONTENT) {
return text.slice(0, MAX_ANALYSIS_CONTENT);
}
return text;
} catch (err) {
console.warn('[FileAnalyzer] Failed to read original file:', err);
}
}
// Fall back to stored textContent
return attachment.textContent || '';
}
// ============================================================================
// Content Cleaning
// ============================================================================
/**
* Remove base64 blobs and large binary data from JSON content
* Replaces them with descriptive placeholders
*/
function cleanJsonForAnalysis(content: string): { cleaned: string; blobsRemoved: number } {
let blobsRemoved = 0;
// Pattern to match base64 data (common patterns in JSON)
// Matches: "data:image/...;base64,..." or long base64 strings (>100 chars of base64 alphabet)
const base64DataUrlPattern = /"data:[^"]*;base64,[A-Za-z0-9+/=]+"/g;
const longBase64Pattern = /"[A-Za-z0-9+/=]{100,}"/g;
let cleaned = content;
// Replace data URLs
cleaned = cleaned.replace(base64DataUrlPattern, (match) => {
blobsRemoved++;
// Extract mime type if possible
const mimeMatch = match.match(/data:([^;]+);/);
const mime = mimeMatch ? mimeMatch[1] : 'binary';
return `"[BLOB: ${mime} data removed]"`;
});
// Replace remaining long base64 strings
cleaned = cleaned.replace(longBase64Pattern, () => {
blobsRemoved++;
return '"[BLOB: large binary data removed]"';
});
return { cleaned, blobsRemoved };
}
// ============================================================================
// Analysis Prompts
// ============================================================================
/**
* Build an analysis prompt based on file type
* @param attachment The file attachment metadata
* @param rawContent The full content to analyze (from original file)
*/
function buildAnalysisPrompt(attachment: FileAttachment, rawContent: string): string {
let content = rawContent;
const fileTypeHint = getFileTypeHint(attachment);
let blobNote = '';
// For JSON files, remove blobs to reduce size
const ext = attachment.filename.split('.').pop()?.toLowerCase();
const mime = attachment.mimeType.toLowerCase();
if (mime === 'application/json' || ext === 'json') {
const { cleaned, blobsRemoved } = cleanJsonForAnalysis(content);
content = cleaned;
if (blobsRemoved > 0) {
blobNote = `\n(Note: ${blobsRemoved} binary blob(s) were removed from the JSON for analysis)`;
}
}
return `Summarize this ${fileTypeHint} in 2-3 sentences. Focus on: what it is, key data/content, and structure.${blobNote}
<file name="${attachment.filename}">
${content}
</file>
Summary:`;
}
/**
* Get a human-readable hint about the file type
*/
function getFileTypeHint(attachment: FileAttachment): string {
const ext = attachment.filename.split('.').pop()?.toLowerCase();
const mime = attachment.mimeType.toLowerCase();
if (mime === 'application/json' || ext === 'json') {
return 'JSON data file';
}
if (mime === 'application/pdf' || ext === 'pdf') {
return 'PDF document';
}
if (ext === 'md' || ext === 'markdown') {
return 'Markdown document';
}
if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp'].includes(ext || '')) {
return `${ext?.toUpperCase()} source code file`;
}
if (['yaml', 'yml', 'toml', 'ini', 'cfg', 'conf'].includes(ext || '')) {
return 'configuration file';
}
if (ext === 'csv') {
return 'CSV data file';
}
if (ext === 'xml') {
return 'XML document';
}
if (ext === 'html' || ext === 'htm') {
return 'HTML document';
}
return 'text file';
}
// ============================================================================
// File Analyzer Class
// ============================================================================
export class FileAnalyzer {
private config: FileAnalyzerConfig;
constructor(config: Partial<FileAnalyzerConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Get the size threshold for a given attachment type
*/
private getThreshold(attachment: FileAttachment): number {
const ext = attachment.filename.split('.').pop()?.toLowerCase();
const mime = attachment.mimeType.toLowerCase();
// JSON files are dense, use lower threshold
if (mime === 'application/json' || ext === 'json') {
return this.config.thresholds.json;
}
// PDFs can be larger
if (mime === 'application/pdf' || ext === 'pdf') {
return this.config.thresholds.pdf;
}
// Default text threshold
return this.config.thresholds.text;
}
/**
* Check if a file should be analyzed (based on content size)
* Skips analysis for borderline files (within 20% of threshold)
*/
shouldAnalyze(attachment: FileAttachment): boolean {
const contentLength = attachment.textContent?.length || 0;
// Below threshold - no analysis needed
if (contentLength <= ANALYSIS_THRESHOLD) {
return false;
}
// Check if borderline (within 20% of threshold)
// These files are small enough to just use directly
const borderlineLimit = ANALYSIS_THRESHOLD * (1 + BORDERLINE_THRESHOLD_PERCENT);
if (contentLength <= borderlineLimit) {
return false;
}
return true;
}
/**
* Analyze a file attachment if needed
* Returns either the original content (for small files) or a summary (for large files)
*/
async analyzeIfNeeded(
attachment: FileAttachment,
model: string
): Promise<AnalysisResult> {
const contentLength = attachment.textContent?.length || 0;
// Small files or borderline: use original content
if (!this.shouldAnalyze(attachment)) {
return {
useOriginal: true,
content: attachment.textContent || '',
originalLength: contentLength,
analyzed: false,
};
}
// Large files: spawn analysis agent with timeout
const startTime = Date.now();
try {
// Race between analysis and timeout
const summary = await Promise.race([
this.spawnAnalysisAgent(attachment, model),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Analysis timeout')), this.config.timeout)
)
]);
return {
useOriginal: false,
content: summary,
summary,
originalLength: contentLength,
analyzed: true,
};
} catch (error) {
const elapsed = Date.now() - startTime;
const isTimeout = error instanceof Error && error.message === 'Analysis timeout';
console.warn(`[FileAnalyzer] Analysis ${isTimeout ? 'TIMEOUT' : 'FAILED'} for ${attachment.filename} after ${elapsed}ms`);
// Fallback: use truncated content (faster than waiting for slow analysis)
const truncated = attachment.textContent?.slice(0, ANALYSIS_THRESHOLD) || '';
const reason = isTimeout ? 'timed out' : 'failed';
return {
useOriginal: false,
content: truncated + `\n\n[Analysis ${reason} - showing first ${formatFileSize(ANALYSIS_THRESHOLD)} of ${formatFileSize(contentLength)}]`,
originalLength: contentLength,
analyzed: false,
error: error instanceof Error ? error.message : 'Analysis failed',
};
}
}
/**
* Spawn a separate Ollama request to analyze the file
*/
private async spawnAnalysisAgent(
attachment: FileAttachment,
model: string
): Promise<string> {
// Read full content from original file if available
const fullContent = await readFullContentForAnalysis(attachment);
const prompt = buildAnalysisPrompt(attachment, fullContent);
// Use generate for a simple completion
const response = await ollamaClient.generate({
model,
prompt,
options: {
temperature: 0.3, // Lower temperature for consistent summaries
num_predict: this.config.maxResponseTokens,
}
});
return response.response.trim();
}
}
// ============================================================================
// Singleton Instance
// ============================================================================
export const fileAnalyzer = new FileAnalyzer();
// ============================================================================
// Batch Analysis Helper
// ============================================================================
/**
* Analyze multiple files with concurrency limit
* @param files Files to analyze
* @param model Model to use
* @param maxConcurrent Maximum parallel analyses (default 2)
*/
export async function analyzeFilesInBatches(
files: FileAttachment[],
model: string,
maxConcurrent: number = 2
): Promise<Map<string, AnalysisResult>> {
const results = new Map<string, AnalysisResult>();
// Process in batches of maxConcurrent
for (let i = 0; i < files.length; i += maxConcurrent) {
const batch = files.slice(i, i + maxConcurrent);
const batchResults = await Promise.all(
batch.map(file => fileAnalyzer.analyzeIfNeeded(file, model))
);
batch.forEach((file, idx) => {
results.set(file.id, batchResults[idx]);
});
}
return results;
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Format an analyzed attachment for inclusion in a message
*/
export function formatAnalyzedAttachment(
attachment: FileAttachment,
result: AnalysisResult
): string {
if (result.analyzed && result.summary) {
return `<file name="${escapeXmlAttr(attachment.filename)}" size="${formatFileSize(attachment.size)}" analyzed="true">
## Summary (original: ${formatFileSize(result.originalLength)} chars)
${result.summary}
</file>`;
}
// Not analyzed, use content directly
return `<file name="${escapeXmlAttr(attachment.filename)}" size="${formatFileSize(attachment.size)}">
${result.content}
</file>`;
}
/**
* Escape special characters for XML attribute values
*/
function escapeXmlAttr(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

View File

@@ -0,0 +1,204 @@
/**
* Model Info Service
*
* Fetches and caches model information from Ollama, including:
* - Embedded system prompts (from Modelfile SYSTEM directive)
* - Model capabilities (vision, code, thinking, tools, etc.)
*
* Uses IndexedDB for persistent caching with configurable TTL.
*/
import { ollamaClient } from '$lib/ollama/client.js';
import { parseSystemPromptFromModelfile } from '$lib/ollama/modelfile-parser.js';
import type { OllamaCapability } from '$lib/ollama/types.js';
import { db, type StoredModelSystemPrompt } from '$lib/storage/db.js';
/** Cache TTL in milliseconds (1 hour) */
const CACHE_TTL_MS = 60 * 60 * 1000;
/** Model info returned by the service */
export interface ModelInfo {
modelName: string;
systemPrompt: string | null;
capabilities: OllamaCapability[];
extractedAt: number;
}
/**
* Service for fetching and caching model information.
* Singleton pattern with in-flight request deduplication.
*/
class ModelInfoService {
/** Track in-flight fetches to prevent duplicate requests */
private fetchingModels = new Map<string, Promise<ModelInfo>>();
/**
* Get model info, fetching from Ollama if not cached or expired.
*
* @param modelName - Ollama model name (e.g., "llama3.2:8b")
* @param forceRefresh - Skip cache and fetch fresh data
* @returns Model info including embedded system prompt and capabilities
*/
async getModelInfo(modelName: string, forceRefresh = false): Promise<ModelInfo> {
// Check cache first (unless force refresh)
if (!forceRefresh) {
const cached = await this.getCached(modelName);
if (cached && Date.now() - cached.extractedAt < CACHE_TTL_MS) {
return {
modelName: cached.modelName,
systemPrompt: cached.systemPrompt,
capabilities: cached.capabilities as OllamaCapability[],
extractedAt: cached.extractedAt
};
}
}
// Check if already fetching this model (deduplication)
const existingFetch = this.fetchingModels.get(modelName);
if (existingFetch) {
return existingFetch;
}
// Create new fetch promise
const fetchPromise = this.fetchAndCache(modelName);
this.fetchingModels.set(modelName, fetchPromise);
try {
return await fetchPromise;
} finally {
this.fetchingModels.delete(modelName);
}
}
/**
* Fetch model info from Ollama and cache it.
*/
private async fetchAndCache(modelName: string): Promise<ModelInfo> {
try {
const response = await ollamaClient.showModel(modelName);
const systemPrompt = parseSystemPromptFromModelfile(response.modelfile);
const capabilities = (response.capabilities ?? []) as OllamaCapability[];
const extractedAt = Date.now();
const record: StoredModelSystemPrompt = {
modelName,
systemPrompt,
capabilities,
extractedAt
};
// Cache in IndexedDB
await db.modelSystemPrompts.put(record);
return {
modelName,
systemPrompt,
capabilities,
extractedAt
};
} catch (error) {
console.error(`[ModelInfoService] Failed to fetch info for ${modelName}:`, error);
// Return cached data if available (even if expired)
const cached = await this.getCached(modelName);
if (cached) {
return {
modelName: cached.modelName,
systemPrompt: cached.systemPrompt,
capabilities: cached.capabilities as OllamaCapability[],
extractedAt: cached.extractedAt
};
}
// Return empty info if no cache
return {
modelName,
systemPrompt: null,
capabilities: [],
extractedAt: 0
};
}
}
/**
* Get cached model info from IndexedDB.
*/
private async getCached(modelName: string): Promise<StoredModelSystemPrompt | undefined> {
try {
return await db.modelSystemPrompts.get(modelName);
} catch (error) {
console.error(`[ModelInfoService] Cache read error for ${modelName}:`, error);
return undefined;
}
}
/**
* Check if a model has an embedded system prompt.
*
* @param modelName - Ollama model name
* @returns true if model has embedded system prompt
*/
async hasEmbeddedPrompt(modelName: string): Promise<boolean> {
const info = await this.getModelInfo(modelName);
return info.systemPrompt !== null;
}
/**
* Get the embedded system prompt for a model.
*
* @param modelName - Ollama model name
* @returns Embedded system prompt or null
*/
async getEmbeddedPrompt(modelName: string): Promise<string | null> {
const info = await this.getModelInfo(modelName);
return info.systemPrompt;
}
/**
* Get capabilities for a model.
*
* @param modelName - Ollama model name
* @returns Array of capability strings
*/
async getCapabilities(modelName: string): Promise<OllamaCapability[]> {
const info = await this.getModelInfo(modelName);
return info.capabilities;
}
/**
* Pre-fetch info for multiple models in parallel.
* Useful for warming the cache on app startup.
*
* @param modelNames - Array of model names to fetch
*/
async prefetchModels(modelNames: string[]): Promise<void> {
await Promise.allSettled(modelNames.map((name) => this.getModelInfo(name)));
}
/**
* Clear cached info for a model.
*
* @param modelName - Ollama model name
*/
async clearCache(modelName: string): Promise<void> {
try {
await db.modelSystemPrompts.delete(modelName);
} catch (error) {
console.error(`[ModelInfoService] Failed to clear cache for ${modelName}:`, error);
}
}
/**
* Clear all cached model info.
*/
async clearAllCache(): Promise<void> {
try {
await db.modelSystemPrompts.clear();
} catch (error) {
console.error('[ModelInfoService] Failed to clear all cache:', error);
}
}
}
/** Singleton instance */
export const modelInfoService = new ModelInfoService();

View File

@@ -0,0 +1,195 @@
/**
* Prompt Resolution Service
*
* Determines which system prompt to use for a chat based on priority:
* 1. Per-conversation prompt (explicit user override)
* 2. New chat prompt selection (before conversation exists)
* 3. Model-prompt mapping (user configured default for model)
* 4. Model-embedded prompt (from Ollama Modelfile SYSTEM directive)
* 5. Capability-matched prompt (user prompt targeting model capabilities)
* 6. Global active prompt
* 7. No prompt
*/
import { promptsState, type Prompt } from '$lib/stores/prompts.svelte.js';
import { modelPromptMappingsState } from '$lib/stores/model-prompt-mappings.svelte.js';
import { modelInfoService } from '$lib/services/model-info-service.js';
import type { OllamaCapability } from '$lib/ollama/types.js';
/** Source of the resolved prompt */
export type PromptSource =
| 'per-conversation'
| 'new-chat-selection'
| 'model-mapping'
| 'model-embedded'
| 'capability-match'
| 'global-active'
| 'none';
/** Result of prompt resolution */
export interface ResolvedPrompt {
/** The system prompt content to use */
content: string;
/** Where this prompt came from */
source: PromptSource;
/** Name of the prompt (for display) */
promptName?: string;
/** Matched capability (if source is capability-match) */
matchedCapability?: OllamaCapability;
}
/** Priority order for capability matching */
const CAPABILITY_PRIORITY: OllamaCapability[] = ['code', 'vision', 'thinking', 'tools'];
/**
* Find a user prompt that targets specific capabilities.
*
* @param capabilities - Model capabilities to match against
* @param prompts - Available user prompts
* @returns Matched prompt and capability, or null
*/
function findCapabilityMatchedPrompt(
capabilities: OllamaCapability[],
prompts: Prompt[]
): { prompt: Prompt; capability: OllamaCapability } | null {
for (const capability of CAPABILITY_PRIORITY) {
if (!capabilities.includes(capability)) continue;
// Find a prompt targeting this capability
const match = prompts.find(
(p) => (p as Prompt & { targetCapabilities?: string[] }).targetCapabilities?.includes(capability)
);
if (match) {
return { prompt: match, capability };
}
}
return null;
}
/**
* Resolve which system prompt to use for a chat.
*
* Priority order:
* 1. Per-conversation prompt (explicit user override)
* 2. New chat prompt selection (before conversation exists)
* 3. Model-prompt mapping (user configured default for model)
* 4. Model-embedded prompt (from Ollama Modelfile)
* 5. Capability-matched prompt
* 6. Global active prompt
* 7. No prompt
*
* @param modelName - Ollama model name (e.g., "llama3.2:8b")
* @param conversationPromptId - Per-conversation prompt ID (if set)
* @param newChatPromptId - New chat selection (before conversation created)
* @returns Resolved prompt with content and source
*/
export async function resolveSystemPrompt(
modelName: string,
conversationPromptId?: string | null,
newChatPromptId?: string | null
): Promise<ResolvedPrompt> {
// Ensure stores are loaded
await promptsState.ready();
await modelPromptMappingsState.ready();
// 1. Per-conversation prompt (highest priority)
if (conversationPromptId) {
const prompt = promptsState.get(conversationPromptId);
if (prompt) {
return {
content: prompt.content,
source: 'per-conversation',
promptName: prompt.name
};
}
}
// 2. New chat prompt selection (before conversation exists)
if (newChatPromptId) {
const prompt = promptsState.get(newChatPromptId);
if (prompt) {
return {
content: prompt.content,
source: 'new-chat-selection',
promptName: prompt.name
};
}
}
// 3. User-configured model-prompt mapping
const mappedPromptId = modelPromptMappingsState.getMapping(modelName);
if (mappedPromptId) {
const prompt = promptsState.get(mappedPromptId);
if (prompt) {
return {
content: prompt.content,
source: 'model-mapping',
promptName: prompt.name
};
}
}
// 4. Model-embedded prompt (from Ollama Modelfile SYSTEM directive)
const modelInfo = await modelInfoService.getModelInfo(modelName);
if (modelInfo.systemPrompt) {
return {
content: modelInfo.systemPrompt,
source: 'model-embedded',
promptName: `${modelName} (embedded)`
};
}
// 5. Capability-matched prompt
if (modelInfo.capabilities.length > 0) {
const capabilityMatch = findCapabilityMatchedPrompt(modelInfo.capabilities, promptsState.prompts);
if (capabilityMatch) {
return {
content: capabilityMatch.prompt.content,
source: 'capability-match',
promptName: capabilityMatch.prompt.name,
matchedCapability: capabilityMatch.capability
};
}
}
// 6. Global active prompt
const activePrompt = promptsState.activePrompt;
if (activePrompt) {
return {
content: activePrompt.content,
source: 'global-active',
promptName: activePrompt.name
};
}
// 7. No prompt
return {
content: '',
source: 'none'
};
}
/**
* Get a human-readable description of a prompt source.
*
* @param source - Prompt source type
* @returns Display string for the source
*/
export function getPromptSourceLabel(source: PromptSource): string {
switch (source) {
case 'per-conversation':
return 'Custom (this chat)';
case 'new-chat-selection':
return 'Selected prompt';
case 'model-mapping':
return 'Model default';
case 'model-embedded':
return 'Model built-in';
case 'capability-match':
return 'Auto-matched';
case 'global-active':
return 'Global default';
case 'none':
return 'None';
}
}

View File

@@ -4,18 +4,15 @@
*/
import { db, withErrorHandling, generateId } from './db.js';
import type { StoredAttachment, StorageResult } from './db.js';
import type { StoredAttachment, AttachmentMeta, StorageResult } from './db.js';
import type { FileAttachment, AttachmentType } from '$lib/types/attachment.js';
/**
* Attachment metadata without the binary data
*/
export interface AttachmentMeta {
id: string;
messageId: string;
mimeType: string;
filename: string;
size: number;
}
// Re-export AttachmentMeta for convenience
export type { AttachmentMeta };
// ============================================================================
// Query Operations
// ============================================================================
/**
* Get all attachments for a message
@@ -29,20 +26,14 @@ export async function getAttachmentsForMessage(
}
/**
* Get attachment metadata (without data) for a message
* Get attachment metadata (without binary data) for a message
*/
export async function getAttachmentMetaForMessage(
messageId: string
): Promise<StorageResult<AttachmentMeta[]>> {
return withErrorHandling(async () => {
const attachments = await db.attachments.where('messageId').equals(messageId).toArray();
return attachments.map((a) => ({
id: a.id,
messageId: a.messageId,
mimeType: a.mimeType,
filename: a.filename,
size: a.data.size
}));
return attachments.map(toAttachmentMeta);
});
}
@@ -56,25 +47,92 @@ export async function getAttachment(id: string): Promise<StorageResult<StoredAtt
}
/**
* Add an attachment to a message
* Get multiple attachments by IDs
*/
export async function addAttachment(
messageId: string,
file: File
): Promise<StorageResult<StoredAttachment>> {
export async function getAttachmentsByIds(ids: string[]): Promise<StorageResult<StoredAttachment[]>> {
return withErrorHandling(async () => {
const id = generateId();
const attachments = await db.attachments.where('id').anyOf(ids).toArray();
// Maintain order of input IDs
const attachmentMap = new Map(attachments.map(a => [a.id, a]));
return ids.map(id => attachmentMap.get(id)).filter((a): a is StoredAttachment => a !== undefined);
});
}
const attachment: StoredAttachment = {
id,
/**
* Get metadata for multiple attachments by IDs
*/
export async function getAttachmentMetaByIds(ids: string[]): Promise<StorageResult<AttachmentMeta[]>> {
return withErrorHandling(async () => {
const attachments = await db.attachments.where('id').anyOf(ids).toArray();
const attachmentMap = new Map(attachments.map(a => [a.id, a]));
return ids
.map(id => attachmentMap.get(id))
.filter((a): a is StoredAttachment => a !== undefined)
.map(toAttachmentMeta);
});
}
// ============================================================================
// Create Operations
// ============================================================================
/**
* Save a FileAttachment to IndexedDB with the original file data
* Returns the attachment ID for linking to the message
*/
export async function saveAttachment(
messageId: string,
file: File,
attachment: FileAttachment
): Promise<StorageResult<string>> {
return withErrorHandling(async () => {
const stored: StoredAttachment = {
id: attachment.id,
messageId,
mimeType: file.type || 'application/octet-stream',
mimeType: attachment.mimeType,
data: file,
filename: file.name
filename: attachment.filename,
size: attachment.size,
type: attachment.type,
createdAt: Date.now(),
textContent: attachment.textContent,
truncated: attachment.truncated,
analyzed: attachment.analyzed,
summary: attachment.summary,
};
await db.attachments.add(attachment);
return attachment;
await db.attachments.add(stored);
return attachment.id;
});
}
/**
* Save multiple attachments at once
* Returns array of attachment IDs
*/
export async function saveAttachments(
messageId: string,
files: File[],
attachments: FileAttachment[]
): Promise<StorageResult<string[]>> {
return withErrorHandling(async () => {
const storedAttachments: StoredAttachment[] = attachments.map((attachment, index) => ({
id: attachment.id,
messageId,
mimeType: attachment.mimeType,
data: files[index],
filename: attachment.filename,
size: attachment.size,
type: attachment.type,
createdAt: Date.now(),
textContent: attachment.textContent,
truncated: attachment.truncated,
analyzed: attachment.analyzed,
summary: attachment.summary,
}));
await db.attachments.bulkAdd(storedAttachments);
return attachments.map(a => a.id);
});
}
@@ -85,7 +143,14 @@ export async function addAttachmentFromBlob(
messageId: string,
data: Blob,
filename: string,
mimeType?: string
type: AttachmentType,
options?: {
mimeType?: string;
textContent?: string;
truncated?: boolean;
analyzed?: boolean;
summary?: string;
}
): Promise<StorageResult<StoredAttachment>> {
return withErrorHandling(async () => {
const id = generateId();
@@ -93,9 +158,16 @@ export async function addAttachmentFromBlob(
const attachment: StoredAttachment = {
id,
messageId,
mimeType: mimeType ?? data.type ?? 'application/octet-stream',
mimeType: options?.mimeType ?? data.type ?? 'application/octet-stream',
data,
filename
filename,
size: data.size,
type,
createdAt: Date.now(),
textContent: options?.textContent,
truncated: options?.truncated,
analyzed: options?.analyzed,
summary: options?.summary,
};
await db.attachments.add(attachment);
@@ -103,6 +175,27 @@ export async function addAttachmentFromBlob(
});
}
// ============================================================================
// Update Operations
// ============================================================================
/**
* Update attachment with analysis results
*/
export async function updateAttachmentAnalysis(
id: string,
analyzed: boolean,
summary?: string
): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.attachments.update(id, { analyzed, summary });
});
}
// ============================================================================
// Delete Operations
// ============================================================================
/**
* Delete an attachment by ID
*/
@@ -121,6 +214,19 @@ export async function deleteAttachmentsForMessage(messageId: string): Promise<St
});
}
/**
* Delete multiple attachments by IDs
*/
export async function deleteAttachmentsByIds(ids: string[]): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.attachments.where('id').anyOf(ids).delete();
});
}
// ============================================================================
// Data Conversion
// ============================================================================
/**
* Get the data URL for an attachment (for displaying images)
*/
@@ -140,6 +246,66 @@ export async function getAttachmentDataUrl(id: string): Promise<StorageResult<st
});
}
/**
* Get base64 data for an image attachment (without data: prefix, for Ollama)
*/
export async function getAttachmentBase64(id: string): Promise<StorageResult<string | null>> {
return withErrorHandling(async () => {
const attachment = await db.attachments.get(id);
if (!attachment || !attachment.mimeType.startsWith('image/')) {
return null;
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
// Remove the data:image/xxx;base64, prefix
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
resolve(base64);
};
reader.onerror = () => reject(new Error('Failed to read attachment data'));
reader.readAsDataURL(attachment.data);
});
});
}
/**
* Get text content from an attachment (reads from cache or blob)
*/
export async function getAttachmentTextContent(id: string): Promise<StorageResult<string | null>> {
return withErrorHandling(async () => {
const attachment = await db.attachments.get(id);
if (!attachment) {
return null;
}
// Return cached text content if available
if (attachment.textContent) {
return attachment.textContent;
}
// For text files, read from blob
if (attachment.type === 'text' || attachment.mimeType.startsWith('text/')) {
return await attachment.data.text();
}
return null;
});
}
/**
* Create a download URL for an attachment
* Remember to call URL.revokeObjectURL() when done
*/
export function createDownloadUrl(attachment: StoredAttachment): string {
return URL.createObjectURL(attachment.data);
}
// ============================================================================
// Storage Statistics
// ============================================================================
/**
* Get total storage size used by attachments
*/
@@ -171,3 +337,33 @@ export async function getConversationAttachmentSize(
return attachments.reduce((total, a) => total + a.data.size, 0);
});
}
/**
* Get attachment count for a message
*/
export async function getAttachmentCountForMessage(messageId: string): Promise<StorageResult<number>> {
return withErrorHandling(async () => {
return await db.attachments.where('messageId').equals(messageId).count();
});
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Convert StoredAttachment to AttachmentMeta (strips binary data)
*/
function toAttachmentMeta(attachment: StoredAttachment): AttachmentMeta {
return {
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
mimeType: attachment.mimeType,
size: attachment.size,
type: attachment.type,
createdAt: attachment.createdAt,
truncated: attachment.truncated,
analyzed: attachment.analyzed,
};
}

View File

@@ -59,6 +59,8 @@ export interface StoredMessage {
siblingIndex: number;
createdAt: number;
syncVersion?: number;
/** References to attachments stored in the attachments table */
attachmentIds?: string[];
}
/**
@@ -85,6 +87,36 @@ export interface StoredAttachment {
mimeType: string;
data: Blob;
filename: string;
/** File size in bytes */
size: number;
/** Attachment type category */
type: 'image' | 'text' | 'pdf' | 'audio' | 'video' | 'binary';
/** Timestamp when attachment was created */
createdAt: number;
/** Cached extracted text (for text/PDF files) */
textContent?: string;
/** Whether the text content was truncated */
truncated?: boolean;
/** Whether this attachment was analyzed by the file analyzer */
analyzed?: boolean;
/** Summary from file analyzer (if analyzed) */
summary?: string;
}
/**
* Attachment metadata (without the binary data)
* Used for displaying attachment info without loading the full blob
*/
export interface AttachmentMeta {
id: string;
messageId: string;
filename: string;
mimeType: string;
size: number;
type: 'image' | 'text' | 'pdf' | 'audio' | 'video' | 'binary';
createdAt: number;
truncated?: boolean;
analyzed?: boolean;
}
/**
@@ -137,6 +169,36 @@ export interface StoredPrompt {
isDefault: boolean;
createdAt: number;
updatedAt: number;
/** Capabilities this prompt is optimized for (for auto-matching) */
targetCapabilities?: string[];
}
/**
* Cached model info including embedded system prompt (from Ollama /api/show)
*/
export interface StoredModelSystemPrompt {
/** Model name (e.g., "llama3.2:8b") - Primary key */
modelName: string;
/** System prompt extracted from modelfile, null if none */
systemPrompt: string | null;
/** Model capabilities (vision, code, thinking, tools, etc.) */
capabilities: string[];
/** Timestamp when this info was fetched */
extractedAt: number;
}
/**
* User-configured model-to-prompt mapping
* Allows users to set default prompts for specific models
*/
export interface StoredModelPromptMapping {
id: string;
/** Ollama model name (e.g., "llama3.2:8b") */
modelName: string;
/** Reference to StoredPrompt.id */
promptId: string;
createdAt: number;
updatedAt: number;
}
/**
@@ -151,6 +213,8 @@ class OllamaDatabase extends Dexie {
documents!: Table<StoredDocument>;
chunks!: Table<StoredChunk>;
prompts!: Table<StoredPrompt>;
modelSystemPrompts!: Table<StoredModelSystemPrompt>;
modelPromptMappings!: Table<StoredModelPromptMapping>;
constructor() {
super('vessel');
@@ -203,6 +267,22 @@ class OllamaDatabase extends Dexie {
chunks: 'id, documentId',
prompts: 'id, name, isDefault, updatedAt'
});
// Version 5: Model-specific system prompts
// Adds: cached model info (with embedded prompts) and user model-prompt mappings
this.version(5).stores({
conversations: 'id, updatedAt, isPinned, isArchived, systemPromptId',
messages: 'id, conversationId, parentId, createdAt',
attachments: 'id, messageId',
syncQueue: 'id, entityType, createdAt',
documents: 'id, name, createdAt, updatedAt',
chunks: 'id, documentId',
prompts: 'id, name, isDefault, updatedAt',
// Cached model info from Ollama /api/show (includes embedded system prompts)
modelSystemPrompts: 'modelName',
// User-configured model-to-prompt mappings
modelPromptMappings: 'id, modelName, promptId'
});
}
}

View File

@@ -48,16 +48,31 @@ export type { MessageSearchResult } from './messages.js';
// Attachment operations
export {
// Query
getAttachmentsForMessage,
getAttachmentMetaForMessage,
getAttachment,
addAttachment,
getAttachmentsByIds,
getAttachmentMetaByIds,
// Create
saveAttachment,
saveAttachments,
addAttachmentFromBlob,
// Update
updateAttachmentAnalysis,
// Delete
deleteAttachment,
deleteAttachmentsForMessage,
deleteAttachmentsByIds,
// Data conversion
getAttachmentDataUrl,
getAttachmentBase64,
getAttachmentTextContent,
createDownloadUrl,
// Statistics
getTotalAttachmentSize,
getConversationAttachmentSize
getConversationAttachmentSize,
getAttachmentCountForMessage
} from './attachments.js';
export type { AttachmentMeta } from './attachments.js';

View File

@@ -18,7 +18,8 @@ function toMessageNode(stored: StoredMessage, childIds: string[]): MessageNode {
role: stored.role,
content: stored.content,
images: stored.images,
toolCalls: stored.toolCalls
toolCalls: stored.toolCalls,
attachmentIds: stored.attachmentIds
},
parentId: stored.parentId,
childIds,
@@ -131,6 +132,7 @@ export async function addMessage(
content: message.content,
images: message.images,
toolCalls: message.toolCalls,
attachmentIds: message.attachmentIds,
siblingIndex,
createdAt: now
};

View File

@@ -0,0 +1,125 @@
/**
* Storage operations for model-prompt mappings.
*
* Allows users to configure default system prompts for specific models.
* When a model is used, its mapped prompt takes priority over the global default.
*/
import {
db,
generateId,
withErrorHandling,
type StorageResult,
type StoredModelPromptMapping
} from './db.js';
// Re-export the type for consumers
export type { StoredModelPromptMapping };
/**
* Get the prompt mapping for a specific model.
*
* @param modelName - Ollama model name (e.g., "llama3.2:8b")
* @returns The mapping if found, null otherwise
*/
export async function getModelPromptMapping(
modelName: string
): Promise<StorageResult<StoredModelPromptMapping | null>> {
return withErrorHandling(async () => {
const mapping = await db.modelPromptMappings.where('modelName').equals(modelName).first();
return mapping ?? null;
});
}
/**
* Get all model-prompt mappings.
*
* @returns Array of all mappings
*/
export async function getAllModelPromptMappings(): Promise<
StorageResult<StoredModelPromptMapping[]>
> {
return withErrorHandling(async () => {
return db.modelPromptMappings.toArray();
});
}
/**
* Set or update the prompt mapping for a model.
* Pass null for promptId to remove the mapping.
*
* @param modelName - Ollama model name
* @param promptId - Prompt ID to map to, or null to remove mapping
*/
export async function setModelPromptMapping(
modelName: string,
promptId: string | null
): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
if (promptId === null) {
// Remove mapping
await db.modelPromptMappings.where('modelName').equals(modelName).delete();
} else {
// Upsert mapping
const existing = await db.modelPromptMappings.where('modelName').equals(modelName).first();
const now = Date.now();
if (existing) {
await db.modelPromptMappings.update(existing.id, {
promptId,
updatedAt: now
});
} else {
await db.modelPromptMappings.add({
id: generateId(),
modelName,
promptId,
createdAt: now,
updatedAt: now
});
}
}
});
}
/**
* Remove the prompt mapping for a model.
*
* @param modelName - Ollama model name
*/
export async function removeModelPromptMapping(modelName: string): Promise<StorageResult<void>> {
return setModelPromptMapping(modelName, null);
}
/**
* Get mappings for multiple models at once.
* Useful for batch operations.
*
* @param modelNames - Array of model names
* @returns Map of model name to prompt ID
*/
export async function getModelPromptMappingsBatch(
modelNames: string[]
): Promise<StorageResult<Map<string, string>>> {
return withErrorHandling(async () => {
const mappings = await db.modelPromptMappings
.where('modelName')
.anyOf(modelNames)
.toArray();
const result = new Map<string, string>();
for (const mapping of mappings) {
result.set(mapping.modelName, mapping.promptId);
}
return result;
});
}
/**
* Clear all model-prompt mappings.
*/
export async function clearAllModelPromptMappings(): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.modelPromptMappings.clear();
});
}

View File

@@ -42,6 +42,7 @@ export async function createPrompt(data: {
content: string;
description?: string;
isDefault?: boolean;
targetCapabilities?: string[];
}): Promise<StorageResult<StoredPrompt>> {
return withErrorHandling(async () => {
const now = Date.now();
@@ -51,6 +52,7 @@ export async function createPrompt(data: {
content: data.content,
description: data.description ?? '',
isDefault: data.isDefault ?? false,
targetCapabilities: data.targetCapabilities,
createdAt: now,
updatedAt: now
};

View File

@@ -149,6 +149,28 @@ export class ChatState {
}
}
/**
* Set the content of the currently streaming message (replaces entirely)
* @param content The new content
*/
setStreamContent(content: string): void {
if (!this.streamingMessageId) return;
this.streamBuffer = content;
const node = this.messageTree.get(this.streamingMessageId);
if (node) {
const updatedNode: MessageNode = {
...node,
message: {
...node.message,
content
}
};
this.messageTree = new Map(this.messageTree).set(this.streamingMessageId, updatedNode);
}
}
/**
* Complete the streaming process
*/
@@ -510,6 +532,41 @@ export class ChatState {
this.streamBuffer = '';
}
/**
* Remove a message from the tree
* Used for temporary messages (like analysis progress indicators)
* @param messageId The message ID to remove
*/
removeMessage(messageId: string): void {
const node = this.messageTree.get(messageId);
if (!node) return;
const newTree = new Map(this.messageTree);
// Remove from parent's childIds
if (node.parentId) {
const parent = newTree.get(node.parentId);
if (parent) {
newTree.set(node.parentId, {
...parent,
childIds: parent.childIds.filter((id) => id !== messageId)
});
}
}
// Update root if this was the root
if (this.rootMessageId === messageId) {
this.rootMessageId = null;
}
// Remove the node
newTree.delete(messageId);
this.messageTree = newTree;
// Remove from active path
this.activePath = this.activePath.filter((id) => id !== messageId);
}
/**
* Load a conversation into the chat state
* @param conversationId The conversation ID

View File

@@ -9,6 +9,7 @@ export { UIState, uiState } from './ui.svelte.js';
export { ToastState, toastState } from './toast.svelte.js';
export { toolsState } from './tools.svelte.js';
export { promptsState } from './prompts.svelte.js';
export { SettingsState, settingsState } from './settings.svelte.js';
export type { Prompt } from './prompts.svelte.js';
export { VersionState, versionState } from './version.svelte.js';

View File

@@ -146,7 +146,8 @@ class LocalModelsState {
const response = await checkForUpdates();
this.updatesAvailable = response.updatesAvailable;
this.modelsWithUpdates = new Set(response.updates.map(m => m.name));
// Handle null/undefined updates array from API
this.modelsWithUpdates = new Set((response.updates ?? []).map(m => m.name));
return response;
} catch (err) {

View File

@@ -0,0 +1,120 @@
/**
* Model creation/editing state management using Svelte 5 runes
* Handles creating custom Ollama models with embedded system prompts
*/
import { ollamaClient } from '$lib/ollama';
import type { OllamaCreateProgress } from '$lib/ollama/types.js';
import { modelsState } from './models.svelte.js';
import { modelInfoService } from '$lib/services/model-info-service.js';
/** Mode of the model editor */
export type ModelEditorMode = 'create' | 'edit';
/** Model creation state class with reactive properties */
class ModelCreationState {
/** Whether a creation/update operation is in progress */
isCreating = $state(false);
/** Current status message from Ollama */
status = $state('');
/** Error message if creation failed */
error = $state<string | null>(null);
/** Abort controller for cancelling operations */
private abortController: AbortController | null = null;
/**
* Create a new custom model with an embedded system prompt
* @param modelName Name for the new model
* @param baseModel Base model to derive from (e.g., "llama3.2:8b")
* @param systemPrompt System prompt to embed
* @returns true if successful, false otherwise
*/
async create(
modelName: string,
baseModel: string,
systemPrompt: string
): Promise<boolean> {
if (this.isCreating) return false;
this.isCreating = true;
this.status = 'Initializing...';
this.error = null;
this.abortController = new AbortController();
try {
await ollamaClient.createModel(
{
model: modelName,
from: baseModel,
system: systemPrompt
},
(progress: OllamaCreateProgress) => {
this.status = progress.status;
},
this.abortController.signal
);
// Refresh models list to show the new model
await modelsState.refresh();
// Clear the model info cache for this model so it gets fresh info
modelInfoService.clearCache(modelName);
this.status = 'Success!';
return true;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
this.error = 'Operation cancelled';
} else {
this.error = err instanceof Error ? err.message : 'Failed to create model';
}
return false;
} finally {
this.isCreating = false;
this.abortController = null;
}
}
/**
* Update an existing model's system prompt
* Note: This re-creates the model with the new prompt (Ollama limitation)
* @param modelName Name of the existing model
* @param baseModel Base model (usually the model's parent or itself)
* @param systemPrompt New system prompt
* @returns true if successful, false otherwise
*/
async update(
modelName: string,
baseModel: string,
systemPrompt: string
): Promise<boolean> {
// Updating is the same as creating with the same name
// Ollama will overwrite the existing model
return this.create(modelName, baseModel, systemPrompt);
}
/**
* Cancel the current operation
*/
cancel(): void {
if (this.abortController) {
this.abortController.abort();
}
}
/**
* Reset the state
*/
reset(): void {
this.isCreating = false;
this.status = '';
this.error = null;
this.abortController = null;
}
}
/** Singleton model creation state instance */
export const modelCreationState = new ModelCreationState();

View File

@@ -0,0 +1,150 @@
/**
* Model-prompt mappings state management using Svelte 5 runes.
*
* Manages user-configured default prompts for specific models.
* When a model is used, its mapped prompt takes priority over the global default.
*/
import {
getAllModelPromptMappings,
setModelPromptMapping,
removeModelPromptMapping,
type StoredModelPromptMapping
} from '$lib/storage/model-prompt-mappings.js';
/**
* Model-prompt mappings state class with reactive properties.
*/
class ModelPromptMappingsState {
/** Map of model name to prompt ID */
mappings = $state<Map<string, string>>(new Map());
/** Loading state */
isLoading = $state(false);
/** Error state */
error = $state<string | null>(null);
/** Promise that resolves when initial load is complete */
private _readyPromise: Promise<void> | null = null;
private _readyResolve: (() => void) | null = null;
constructor() {
// Create ready promise
this._readyPromise = new Promise((resolve) => {
this._readyResolve = resolve;
});
// Load mappings on initialization (client-side only)
if (typeof window !== 'undefined') {
this.load();
}
}
/**
* Wait for initial load to complete.
*/
async ready(): Promise<void> {
return this._readyPromise ?? Promise.resolve();
}
/**
* Load all mappings from storage.
*/
async load(): Promise<void> {
this.isLoading = true;
this.error = null;
try {
const result = await getAllModelPromptMappings();
if (result.success) {
this.mappings = new Map(result.data.map((m) => [m.modelName, m.promptId]));
} else {
this.error = result.error;
}
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to load model-prompt mappings';
} finally {
this.isLoading = false;
this._readyResolve?.();
}
}
/**
* Get the prompt ID mapped to a model.
*
* @param modelName - Ollama model name
* @returns Prompt ID or undefined if not mapped
*/
getMapping(modelName: string): string | undefined {
return this.mappings.get(modelName);
}
/**
* Check if a model has a prompt mapping.
*
* @param modelName - Ollama model name
* @returns true if model has a mapping
*/
hasMapping(modelName: string): boolean {
return this.mappings.has(modelName);
}
/**
* Set or update the prompt mapping for a model.
*
* @param modelName - Ollama model name
* @param promptId - Prompt ID to map to
* @returns true if successful
*/
async setMapping(modelName: string, promptId: string): Promise<boolean> {
const result = await setModelPromptMapping(modelName, promptId);
if (result.success) {
// Update local state
const newMap = new Map(this.mappings);
newMap.set(modelName, promptId);
this.mappings = newMap;
return true;
}
this.error = result.error;
return false;
}
/**
* Remove the prompt mapping for a model.
*
* @param modelName - Ollama model name
* @returns true if successful
*/
async removeMapping(modelName: string): Promise<boolean> {
const result = await removeModelPromptMapping(modelName);
if (result.success) {
// Update local state
const newMap = new Map(this.mappings);
newMap.delete(modelName);
this.mappings = newMap;
return true;
}
this.error = result.error;
return false;
}
/**
* Get all mappings as an array.
*
* @returns Array of [modelName, promptId] pairs
*/
getAllMappings(): Array<[string, string]> {
return Array.from(this.mappings.entries());
}
/**
* Get number of configured mappings.
*/
get count(): number {
return this.mappings.size;
}
}
/** Singleton instance */
export const modelPromptMappingsState = new ModelPromptMappingsState();

View File

@@ -5,12 +5,15 @@
import {
fetchRemoteModels,
fetchRemoteFamilies,
getSyncStatus,
syncModels,
type RemoteModel,
type SyncStatus,
type ModelSearchOptions,
type ModelSortOption
type ModelSortOption,
type SizeRange,
type ContextRange
} from '$lib/api/model-registry';
/** Store state */
@@ -25,6 +28,10 @@ class ModelRegistryState {
searchQuery = $state('');
modelType = $state<'official' | 'community' | ''>('');
selectedCapabilities = $state<string[]>([]);
selectedSizeRanges = $state<SizeRange[]>([]);
selectedContextRanges = $state<ContextRange[]>([]);
selectedFamily = $state<string>('');
availableFamilies = $state<string[]>([]);
sortBy = $state<ModelSortOption>('pulls_desc');
currentPage = $state(0);
pageSize = $state(24);
@@ -69,6 +76,18 @@ class ModelRegistryState {
options.capabilities = this.selectedCapabilities;
}
if (this.selectedSizeRanges.length > 0) {
options.sizeRanges = this.selectedSizeRanges;
}
if (this.selectedContextRanges.length > 0) {
options.contextRanges = this.selectedContextRanges;
}
if (this.selectedFamily) {
options.family = this.selectedFamily;
}
const response = await fetchRemoteModels(options);
this.models = response.models;
this.total = response.total;
@@ -119,6 +138,68 @@ class ModelRegistryState {
return this.selectedCapabilities.includes(capability);
}
/**
* Toggle a size range filter
*/
async toggleSizeRange(size: SizeRange): Promise<void> {
const index = this.selectedSizeRanges.indexOf(size);
if (index === -1) {
this.selectedSizeRanges = [...this.selectedSizeRanges, size];
} else {
this.selectedSizeRanges = this.selectedSizeRanges.filter((s) => s !== size);
}
this.currentPage = 0;
await this.loadModels();
}
/**
* Check if a size range is selected
*/
hasSizeRange(size: SizeRange): boolean {
return this.selectedSizeRanges.includes(size);
}
/**
* Toggle a context range filter
*/
async toggleContextRange(range: ContextRange): Promise<void> {
const index = this.selectedContextRanges.indexOf(range);
if (index === -1) {
this.selectedContextRanges = [...this.selectedContextRanges, range];
} else {
this.selectedContextRanges = this.selectedContextRanges.filter((r) => r !== range);
}
this.currentPage = 0;
await this.loadModels();
}
/**
* Check if a context range is selected
*/
hasContextRange(range: ContextRange): boolean {
return this.selectedContextRanges.includes(range);
}
/**
* Set family filter
*/
async setFamily(family: string): Promise<void> {
this.selectedFamily = family;
this.currentPage = 0;
await this.loadModels();
}
/**
* Load available families for filter dropdown
*/
async loadFamilies(): Promise<void> {
try {
this.availableFamilies = await fetchRemoteFamilies();
} catch (err) {
console.error('Failed to load families:', err);
}
}
/**
* Set sort order
*/
@@ -200,6 +281,9 @@ class ModelRegistryState {
this.searchQuery = '';
this.modelType = '';
this.selectedCapabilities = [];
this.selectedSizeRanges = [];
this.selectedContextRanges = [];
this.selectedFamily = '';
this.sortBy = 'pulls_desc';
this.currentPage = 0;
await this.loadModels();
@@ -209,7 +293,7 @@ class ModelRegistryState {
* Initialize the store
*/
async init(): Promise<void> {
await Promise.all([this.loadSyncStatus(), this.loadModels()]);
await Promise.all([this.loadSyncStatus(), this.loadModels(), this.loadFamilies()]);
}
}

View File

@@ -65,6 +65,14 @@ export interface ModelUpdateStatus {
localModifiedAt: string;
}
/** Model default parameters from Ollama */
export interface ModelDefaults {
temperature?: number;
top_k?: number;
top_p?: number;
num_ctx?: number;
}
/** Models state class with reactive properties */
export class ModelsState {
// Core state
@@ -81,6 +89,10 @@ export class ModelsState {
private capabilitiesCache = $state<Map<string, OllamaCapability[]>>(new Map());
private capabilitiesFetching = new Set<string>();
// Model defaults cache: modelName -> default parameters
private modelDefaultsCache = $state<Map<string, ModelDefaults>>(new Map());
private modelDefaultsFetching = new Set<string>();
// Derived: Currently selected model
selected = $derived.by(() => {
if (!this.selectedId) return null;
@@ -429,6 +441,99 @@ export class ModelsState {
}
return count;
}
// =========================================================================
// Model Defaults
// =========================================================================
/**
* Parse model parameters from the Ollama show response
* The parameters field contains lines like "temperature 0.7" or "num_ctx 4096"
*/
private parseModelParameters(parametersStr: string): ModelDefaults {
const defaults: ModelDefaults = {};
if (!parametersStr) return defaults;
const lines = parametersStr.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Parse "key value" format
const spaceIndex = trimmed.indexOf(' ');
if (spaceIndex === -1) continue;
const key = trimmed.substring(0, spaceIndex).toLowerCase();
const value = trimmed.substring(spaceIndex + 1).trim();
switch (key) {
case 'temperature':
defaults.temperature = parseFloat(value);
break;
case 'top_k':
defaults.top_k = parseInt(value, 10);
break;
case 'top_p':
defaults.top_p = parseFloat(value);
break;
case 'num_ctx':
defaults.num_ctx = parseInt(value, 10);
break;
}
}
return defaults;
}
/**
* Fetch model defaults from Ollama /api/show
*/
async fetchModelDefaults(modelName: string): Promise<ModelDefaults> {
// Check cache first
const cached = this.modelDefaultsCache.get(modelName);
if (cached) return cached;
// Avoid duplicate fetches
if (this.modelDefaultsFetching.has(modelName)) {
await new Promise((r) => setTimeout(r, 100));
return this.modelDefaultsCache.get(modelName) ?? {};
}
this.modelDefaultsFetching.add(modelName);
try {
const response = await ollamaClient.showModel(modelName);
const defaults = this.parseModelParameters(response.parameters);
// Update cache reactively
const newCache = new Map(this.modelDefaultsCache);
newCache.set(modelName, defaults);
this.modelDefaultsCache = newCache;
return defaults;
} catch (err) {
console.warn(`Failed to fetch defaults for ${modelName}:`, err);
return {};
} finally {
this.modelDefaultsFetching.delete(modelName);
}
}
/**
* Get cached model defaults (returns empty if not fetched)
*/
getModelDefaults(modelName: string): ModelDefaults {
return this.modelDefaultsCache.get(modelName) ?? {};
}
/**
* Get defaults for selected model
*/
get selectedModelDefaults(): ModelDefaults {
if (!this.selectedId) return {};
return this.modelDefaultsCache.get(this.selectedId) ?? {};
}
}
/** Singleton models state instance */

View File

@@ -21,6 +21,7 @@ export interface Prompt {
content: string;
description: string;
isDefault: boolean;
targetCapabilities?: string[];
createdAt: Date;
updatedAt: Date;
}
@@ -127,6 +128,7 @@ class PromptsState {
content: string;
description?: string;
isDefault?: boolean;
targetCapabilities?: string[];
}): Promise<Prompt | null> {
try {
const result = await createPrompt(data);
@@ -158,7 +160,7 @@ class PromptsState {
*/
async update(
id: string,
updates: Partial<{ name: string; content: string; description: string; isDefault: boolean }>
updates: Partial<{ name: string; content: string; description: string; isDefault: boolean; targetCapabilities: string[] }>
): Promise<boolean> {
try {
const result = await updatePrompt(id, updates);

View File

@@ -6,10 +6,14 @@
import {
type ModelParameters,
type ChatSettings,
type AutoCompactSettings,
DEFAULT_MODEL_PARAMETERS,
DEFAULT_CHAT_SETTINGS,
PARAMETER_RANGES
DEFAULT_AUTO_COMPACT_SETTINGS,
PARAMETER_RANGES,
AUTO_COMPACT_RANGES
} from '$lib/types/settings';
import type { ModelDefaults } from './models.svelte';
const STORAGE_KEY = 'vessel-settings';
@@ -29,6 +33,11 @@ export class SettingsState {
// Panel visibility
isPanelOpen = $state(false);
// Auto-compact settings
autoCompactEnabled = $state(DEFAULT_AUTO_COMPACT_SETTINGS.enabled);
autoCompactThreshold = $state(DEFAULT_AUTO_COMPACT_SETTINGS.threshold);
autoCompactPreserveCount = $state(DEFAULT_AUTO_COMPACT_SETTINGS.preserveCount);
// Derived: Current model parameters object
modelParameters = $derived.by((): ModelParameters => ({
temperature: this.temperature,
@@ -79,12 +88,30 @@ export class SettingsState {
/**
* Toggle whether to use custom parameters
* When enabling, optionally initialize from model defaults
*/
toggleCustomParameters(): void {
toggleCustomParameters(modelDefaults?: ModelDefaults): void {
this.useCustomParameters = !this.useCustomParameters;
// When enabling custom parameters, initialize from model defaults if provided
if (this.useCustomParameters && modelDefaults) {
this.initializeFromModelDefaults(modelDefaults);
}
this.saveToStorage();
}
/**
* Initialize parameters from model defaults
* Falls back to hardcoded defaults for any missing values
*/
initializeFromModelDefaults(modelDefaults: ModelDefaults): void {
this.temperature = modelDefaults.temperature ?? DEFAULT_MODEL_PARAMETERS.temperature;
this.top_k = modelDefaults.top_k ?? DEFAULT_MODEL_PARAMETERS.top_k;
this.top_p = modelDefaults.top_p ?? DEFAULT_MODEL_PARAMETERS.top_p;
this.num_ctx = modelDefaults.num_ctx ?? DEFAULT_MODEL_PARAMETERS.num_ctx;
}
/**
* Update a single parameter
*/
@@ -112,14 +139,39 @@ export class SettingsState {
}
/**
* Reset all parameters to defaults
* Reset all parameters to model defaults (or hardcoded defaults if not available)
*/
resetToDefaults(): void {
this.temperature = DEFAULT_MODEL_PARAMETERS.temperature;
this.top_k = DEFAULT_MODEL_PARAMETERS.top_k;
this.top_p = DEFAULT_MODEL_PARAMETERS.top_p;
this.num_ctx = DEFAULT_MODEL_PARAMETERS.num_ctx;
this.useCustomParameters = false;
resetToDefaults(modelDefaults?: ModelDefaults): void {
this.temperature = modelDefaults?.temperature ?? DEFAULT_MODEL_PARAMETERS.temperature;
this.top_k = modelDefaults?.top_k ?? DEFAULT_MODEL_PARAMETERS.top_k;
this.top_p = modelDefaults?.top_p ?? DEFAULT_MODEL_PARAMETERS.top_p;
this.num_ctx = modelDefaults?.num_ctx ?? DEFAULT_MODEL_PARAMETERS.num_ctx;
this.saveToStorage();
}
/**
* Toggle auto-compact enabled state
*/
toggleAutoCompact(): void {
this.autoCompactEnabled = !this.autoCompactEnabled;
this.saveToStorage();
}
/**
* Update auto-compact threshold
*/
updateAutoCompactThreshold(value: number): void {
const range = AUTO_COMPACT_RANGES.threshold;
this.autoCompactThreshold = Math.max(range.min, Math.min(range.max, value));
this.saveToStorage();
}
/**
* Update auto-compact preserve count
*/
updateAutoCompactPreserveCount(value: number): void {
const range = AUTO_COMPACT_RANGES.preserveCount;
this.autoCompactPreserveCount = Math.max(range.min, Math.min(range.max, Math.round(value)));
this.saveToStorage();
}
@@ -133,11 +185,17 @@ export class SettingsState {
const settings: ChatSettings = JSON.parse(stored);
// Model parameters
this.useCustomParameters = settings.useCustomParameters ?? false;
this.temperature = settings.modelParameters?.temperature ?? DEFAULT_MODEL_PARAMETERS.temperature;
this.top_k = settings.modelParameters?.top_k ?? DEFAULT_MODEL_PARAMETERS.top_k;
this.top_p = settings.modelParameters?.top_p ?? DEFAULT_MODEL_PARAMETERS.top_p;
this.num_ctx = settings.modelParameters?.num_ctx ?? DEFAULT_MODEL_PARAMETERS.num_ctx;
// Auto-compact settings
this.autoCompactEnabled = settings.autoCompact?.enabled ?? DEFAULT_AUTO_COMPACT_SETTINGS.enabled;
this.autoCompactThreshold = settings.autoCompact?.threshold ?? DEFAULT_AUTO_COMPACT_SETTINGS.threshold;
this.autoCompactPreserveCount = settings.autoCompact?.preserveCount ?? DEFAULT_AUTO_COMPACT_SETTINGS.preserveCount;
} catch (error) {
console.warn('[Settings] Failed to load from localStorage:', error);
}
@@ -150,7 +208,12 @@ export class SettingsState {
try {
const settings: ChatSettings = {
useCustomParameters: this.useCustomParameters,
modelParameters: this.modelParameters
modelParameters: this.modelParameters,
autoCompact: {
enabled: this.autoCompactEnabled,
threshold: this.autoCompactThreshold,
preserveCount: this.autoCompactPreserveCount
}
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));

View File

@@ -110,8 +110,25 @@ class ToolsState {
return [];
}
const definitions = toolRegistry.getDefinitions();
return definitions.filter(def => this.isToolEnabled(def.function.name));
// Get enabled builtin tools
const builtinDefs = toolRegistry.getDefinitions();
const enabled = builtinDefs.filter(def => this.isToolEnabled(def.function.name));
// Add enabled custom tools
for (const custom of this.customTools) {
if (custom.enabled && this.isToolEnabled(custom.name)) {
enabled.push({
type: 'function',
function: {
name: custom.name,
description: custom.description,
parameters: custom.parameters
}
});
}
}
return enabled;
}
/**

View File

@@ -292,7 +292,9 @@ class MathParser {
const mathParser = new MathParser();
const calculateHandler: BuiltinToolHandler<CalculateArgs> = (args) => {
const { expression, precision = 10 } = args;
const { expression } = args;
// Coerce to number - Ollama models sometimes output numbers as strings
const precision = Number(args.precision) || 10;
try {
const result = mathParser.parse(expression);
@@ -423,7 +425,10 @@ async function fetchViaProxy(url: string, maxLength: number, timeout: number): P
}
const fetchUrlHandler: BuiltinToolHandler<FetchUrlArgs> = async (args) => {
const { url, extract = 'text', maxLength = 50000, timeout = 30 } = args;
const { url, extract = 'text' } = args;
// Coerce to numbers - Ollama models sometimes output numbers as strings
const maxLength = Number(args.maxLength) || 50000;
const timeout = Number(args.timeout) || 30;
try {
const parsedUrl = new URL(url);
@@ -683,7 +688,10 @@ const webSearchDefinition: ToolDefinition = {
};
const webSearchHandler: BuiltinToolHandler<WebSearchArgs> = async (args) => {
const { query, maxResults = 5, site, freshness, region, timeout } = args;
const { query, site, freshness, region } = args;
// Coerce to numbers - Ollama models sometimes output numbers as strings
const maxResults = Number(args.maxResults) || 5;
const timeout = Number(args.timeout) || undefined;
if (!query || query.trim() === '') {
return { error: 'Search query is required' };

View File

@@ -41,6 +41,45 @@ async function executeJavaScriptTool(tool: CustomTool, args: Record<string, unkn
}
}
/**
* Execute a custom Python tool via backend API
*
* SECURITY NOTE: This sends user-provided Python code to the backend for execution.
* This is by design - users create custom tools with their own code.
* The backend executes code in a subprocess with timeout protection.
*/
async function executePythonTool(tool: CustomTool, args: Record<string, unknown>): Promise<unknown> {
if (!tool.code) {
throw new Error('Python tool has no code');
}
try {
const response = await fetch('/api/v1/tools/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
language: 'python',
code: tool.code,
args,
timeout: 30
})
});
if (!response.ok) {
throw new Error(`Backend error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Python execution failed');
}
return data.result;
} catch (error) {
throw new Error(`Python tool error: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Execute a custom HTTP tool
*/
@@ -168,6 +207,8 @@ export async function executeCustomTool(
switch (tool.implementation) {
case 'javascript':
return executeJavaScriptTool(tool, args);
case 'python':
return executePythonTool(tool, args);
case 'http':
return executeHttpTool(tool, args);
default:

View File

@@ -21,3 +21,10 @@ export {
defaultToolConfig,
type ToolConfig
} from './config.js';
export {
toolTemplates,
getTemplatesByLanguage,
getTemplatesByCategory,
getTemplateById,
type ToolTemplate
} from './templates.js';

View File

@@ -0,0 +1,530 @@
/**
* Tool templates - Starter templates for custom tools
*/
import type { JSONSchema, ToolImplementation } from './types';
export interface ToolTemplate {
id: string;
name: string;
description: string;
category: 'api' | 'data' | 'utility' | 'integration';
language: ToolImplementation;
code: string;
parameters: JSONSchema;
}
export const toolTemplates: ToolTemplate[] = [
// JavaScript Templates
{
id: 'js-api-fetch',
name: 'API Request',
description: 'Fetch data from an external REST API',
category: 'api',
language: 'javascript',
code: `// Fetch data from an API endpoint
const response = await fetch(args.url, {
method: args.method || 'GET',
headers: {
'Content-Type': 'application/json',
...(args.headers || {})
},
...(args.body ? { body: JSON.stringify(args.body) } : {})
});
if (!response.ok) {
throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);
}
return await response.json();`,
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'The API endpoint URL' },
method: { type: 'string', description: 'HTTP method (GET, POST, etc.)' },
headers: { type: 'object', description: 'Additional headers' },
body: { type: 'object', description: 'Request body for POST/PUT' }
},
required: ['url']
}
},
{
id: 'js-json-transform',
name: 'JSON Transform',
description: 'Transform and filter JSON data',
category: 'data',
language: 'javascript',
code: `// Transform JSON data
const data = args.data;
const fields = args.fields || Object.keys(data[0] || data);
// Handle both arrays and single objects
const items = Array.isArray(data) ? data : [data];
const result = items.map(item => {
const filtered = {};
for (const field of fields) {
if (field in item) {
filtered[field] = item[field];
}
}
return filtered;
});
return Array.isArray(data) ? result : result[0];`,
parameters: {
type: 'object',
properties: {
data: { type: 'object', description: 'JSON data to transform' },
fields: { type: 'array', description: 'Fields to keep (optional)' }
},
required: ['data']
}
},
{
id: 'js-string-utils',
name: 'String Utilities',
description: 'Common string manipulation operations',
category: 'utility',
language: 'javascript',
code: `// String manipulation utilities
const text = args.text;
const operation = args.operation;
switch (operation) {
case 'uppercase':
return text.toUpperCase();
case 'lowercase':
return text.toLowerCase();
case 'capitalize':
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
case 'reverse':
return text.split('').reverse().join('');
case 'word_count':
return { count: text.split(/\\s+/).filter(w => w).length };
case 'char_count':
return { count: text.length };
case 'slug':
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
default:
return { text, error: 'Unknown operation' };
}`,
parameters: {
type: 'object',
properties: {
text: { type: 'string', description: 'Input text to process' },
operation: { type: 'string', description: 'Operation: uppercase, lowercase, capitalize, reverse, word_count, char_count, slug' }
},
required: ['text', 'operation']
}
},
{
id: 'js-date-utils',
name: 'Date Utilities',
description: 'Date formatting and calculations',
category: 'utility',
language: 'javascript',
code: `// Date utilities
const date = args.date ? new Date(args.date) : new Date();
const format = args.format || 'iso';
const formatDate = (d, fmt) => {
const pad = n => String(n).padStart(2, '0');
switch (fmt) {
case 'iso':
return d.toISOString();
case 'date':
return d.toLocaleDateString();
case 'time':
return d.toLocaleTimeString();
case 'unix':
return Math.floor(d.getTime() / 1000);
case 'relative':
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return \`\${mins} minutes ago\`;
const hours = Math.floor(mins / 60);
if (hours < 24) return \`\${hours} hours ago\`;
return \`\${Math.floor(hours / 24)} days ago\`;
default:
return d.toISOString();
}
};
return {
formatted: formatDate(date, format),
timestamp: date.getTime(),
iso: date.toISOString()
};`,
parameters: {
type: 'object',
properties: {
date: { type: 'string', description: 'Date string or timestamp (default: now)' },
format: { type: 'string', description: 'Format: iso, date, time, unix, relative' }
}
}
},
{
id: 'js-design-brief',
name: 'Design Brief Generator',
description: 'Generate structured design briefs from project requirements',
category: 'utility',
language: 'javascript',
code: `// Generate a structured design brief from requirements
const projectType = args.project_type || 'website';
const style = args.style_preferences || 'modern, clean';
const features = args.key_features || '';
const audience = args.target_audience || 'general users';
const brand = args.brand_keywords || '';
const brief = {
project_type: projectType,
design_direction: {
style: style,
mood: style.includes('playful') ? 'energetic and fun' :
style.includes('corporate') ? 'professional and trustworthy' :
style.includes('minimal') ? 'clean and focused' :
'balanced and approachable',
inspiration_keywords: [
...style.split(',').map(s => s.trim()),
projectType,
...(brand ? brand.split(',').map(s => s.trim()) : [])
].filter(Boolean)
},
target_audience: audience,
key_sections: features ? features.split(',').map(f => f.trim()) : [
'Hero section with clear value proposition',
'Features/Benefits overview',
'Social proof or testimonials',
'Call to action'
],
ui_recommendations: {
typography: style.includes('modern') ? 'Sans-serif (Inter, Geist, or similar)' :
style.includes('elegant') ? 'Serif accents with sans-serif body' :
'Clean sans-serif for readability',
color_approach: style.includes('minimal') ? 'Monochromatic with single accent' :
style.includes('bold') ? 'High contrast with vibrant accents' :
'Balanced palette with primary and secondary colors',
spacing: 'Generous whitespace for visual breathing room',
imagery: style.includes('corporate') ? 'Professional photography or abstract graphics' :
style.includes('playful') ? 'Illustrations or playful iconography' :
'High-quality, contextual imagery'
},
accessibility_notes: [
'Ensure 4.5:1 contrast ratio for text',
'Include focus states for keyboard navigation',
'Use semantic HTML structure',
'Provide alt text for all images'
]
};
return brief;`,
parameters: {
type: 'object',
properties: {
project_type: {
type: 'string',
description: 'Type of project (landing page, dashboard, mobile app, e-commerce, portfolio, etc.)'
},
style_preferences: {
type: 'string',
description: 'Preferred style keywords (modern, minimal, playful, corporate, elegant, bold, etc.)'
},
key_features: {
type: 'string',
description: 'Comma-separated list of main features or sections needed'
},
target_audience: {
type: 'string',
description: 'Description of target users (developers, enterprise, consumers, etc.)'
},
brand_keywords: {
type: 'string',
description: 'Keywords that describe the brand personality'
}
},
required: ['project_type']
}
},
{
id: 'js-color-palette',
name: 'Color Palette Generator',
description: 'Generate harmonious color palettes from a base color',
category: 'utility',
language: 'javascript',
code: `// Generate color palette from base color
const hexToHsl = (hex) => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
};
const hslToHex = (h, s, l) => {
s /= 100; l /= 100;
const a = s * Math.min(l, 1 - l);
const f = n => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return '#' + f(0) + f(8) + f(4);
};
const baseColor = args.base_color || '#3b82f6';
const harmony = args.harmony || 'complementary';
const base = hexToHsl(baseColor);
const colors = { primary: baseColor };
switch (harmony) {
case 'complementary':
colors.secondary = hslToHex((base.h + 180) % 360, base.s, base.l);
colors.accent = hslToHex((base.h + 30) % 360, base.s, base.l);
break;
case 'analogous':
colors.secondary = hslToHex((base.h + 30) % 360, base.s, base.l);
colors.accent = hslToHex((base.h - 30 + 360) % 360, base.s, base.l);
break;
case 'triadic':
colors.secondary = hslToHex((base.h + 120) % 360, base.s, base.l);
colors.accent = hslToHex((base.h + 240) % 360, base.s, base.l);
break;
case 'split-complementary':
colors.secondary = hslToHex((base.h + 150) % 360, base.s, base.l);
colors.accent = hslToHex((base.h + 210) % 360, base.s, base.l);
break;
}
// Add neutrals
colors.background = hslToHex(base.h, 10, 98);
colors.surface = hslToHex(base.h, 10, 95);
colors.text = hslToHex(base.h, 10, 15);
colors.muted = hslToHex(base.h, 10, 45);
// Add primary shades
colors.primary_light = hslToHex(base.h, base.s, Math.min(base.l + 20, 95));
colors.primary_dark = hslToHex(base.h, base.s, Math.max(base.l - 20, 15));
return {
harmony,
palette: colors,
css_variables: Object.entries(colors).map(([k, v]) => \`--color-\${k.replace('_', '-')}: \${v};\`).join('\\n')
};`,
parameters: {
type: 'object',
properties: {
base_color: {
type: 'string',
description: 'Base color in hex format (e.g., #3b82f6)'
},
harmony: {
type: 'string',
description: 'Color harmony: complementary, analogous, triadic, split-complementary'
}
},
required: ['base_color']
}
},
// Python Templates
{
id: 'py-api-fetch',
name: 'API Request (Python)',
description: 'Fetch data from an external REST API using Python',
category: 'api',
language: 'python',
code: `# Fetch data from an API endpoint
import json
import urllib.request
import urllib.error
url = args.get('url')
method = args.get('method', 'GET')
headers = args.get('headers', {})
body = args.get('body')
req = urllib.request.Request(url, method=method)
req.add_header('Content-Type', 'application/json')
for key, value in headers.items():
req.add_header(key, value)
data = json.dumps(body).encode() if body else None
try:
with urllib.request.urlopen(req, data=data) as response:
result = json.loads(response.read().decode())
print(json.dumps(result))
except urllib.error.HTTPError as e:
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))`,
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'The API endpoint URL' },
method: { type: 'string', description: 'HTTP method (GET, POST, etc.)' },
headers: { type: 'object', description: 'Additional headers' },
body: { type: 'object', description: 'Request body for POST/PUT' }
},
required: ['url']
}
},
{
id: 'py-data-analysis',
name: 'Data Analysis (Python)',
description: 'Basic statistical analysis of numeric data',
category: 'data',
language: 'python',
code: `# Basic data analysis
import json
import math
data = args.get('data', [])
if not data:
print(json.dumps({"error": "No data provided"}))
else:
n = len(data)
total = sum(data)
mean = total / n
sorted_data = sorted(data)
mid = n // 2
median = sorted_data[mid] if n % 2 else (sorted_data[mid-1] + sorted_data[mid]) / 2
variance = sum((x - mean) ** 2 for x in data) / n
std_dev = math.sqrt(variance)
result = {
"count": n,
"sum": total,
"mean": round(mean, 4),
"median": median,
"min": min(data),
"max": max(data),
"std_dev": round(std_dev, 4),
"variance": round(variance, 4)
}
print(json.dumps(result))`,
parameters: {
type: 'object',
properties: {
data: { type: 'array', description: 'Array of numbers to analyze' }
},
required: ['data']
}
},
{
id: 'py-text-analysis',
name: 'Text Analysis (Python)',
description: 'Analyze text for word frequency, sentiment indicators',
category: 'data',
language: 'python',
code: `# Text analysis
import json
import re
from collections import Counter
text = args.get('text', '')
top_n = args.get('top_n', 10)
# Tokenize and count
words = re.findall(r'\\b\\w+\\b', text.lower())
word_freq = Counter(words)
# Basic stats
sentences = re.split(r'[.!?]+', text)
sentences = [s.strip() for s in sentences if s.strip()]
result = {
"word_count": len(words),
"unique_words": len(word_freq),
"sentence_count": len(sentences),
"avg_word_length": round(sum(len(w) for w in words) / len(words), 2) if words else 0,
"top_words": dict(word_freq.most_common(top_n)),
"char_count": len(text),
"char_count_no_spaces": len(text.replace(' ', ''))
}
print(json.dumps(result))`,
parameters: {
type: 'object',
properties: {
text: { type: 'string', description: 'Text to analyze' },
top_n: { type: 'number', description: 'Number of top words to return (default: 10)' }
},
required: ['text']
}
},
{
id: 'py-hash-encode',
name: 'Hash & Encode (Python)',
description: 'Hash strings and encode/decode base64',
category: 'utility',
language: 'python',
code: `# Hash and encoding utilities
import json
import hashlib
import base64
text = args.get('text', '')
operation = args.get('operation', 'md5')
result = {}
if operation == 'md5':
result['hash'] = hashlib.md5(text.encode()).hexdigest()
elif operation == 'sha256':
result['hash'] = hashlib.sha256(text.encode()).hexdigest()
elif operation == 'sha512':
result['hash'] = hashlib.sha512(text.encode()).hexdigest()
elif operation == 'base64_encode':
result['encoded'] = base64.b64encode(text.encode()).decode()
elif operation == 'base64_decode':
try:
result['decoded'] = base64.b64decode(text.encode()).decode()
except Exception as e:
result['error'] = str(e)
else:
result['error'] = f'Unknown operation: {operation}'
result['operation'] = operation
result['input_length'] = len(text)
print(json.dumps(result))`,
parameters: {
type: 'object',
properties: {
text: { type: 'string', description: 'Text to process' },
operation: { type: 'string', description: 'Operation: md5, sha256, sha512, base64_encode, base64_decode' }
},
required: ['text', 'operation']
}
}
];
export function getTemplatesByLanguage(language: ToolImplementation): ToolTemplate[] {
return toolTemplates.filter(t => t.language === language);
}
export function getTemplatesByCategory(category: ToolTemplate['category']): ToolTemplate[] {
return toolTemplates.filter(t => t.category === category);
}
export function getTemplateById(id: string): ToolTemplate | undefined {
return toolTemplates.find(t => t.id === id);
}

View File

@@ -54,7 +54,7 @@ export interface ToolResult {
}
/** Tool implementation type */
export type ToolImplementation = 'builtin' | 'javascript' | 'http';
export type ToolImplementation = 'builtin' | 'javascript' | 'python' | 'http';
/** Custom tool configuration */
export interface CustomTool {
@@ -63,7 +63,7 @@ export interface CustomTool {
description: string;
parameters: JSONSchema;
implementation: ToolImplementation;
/** JavaScript code for 'javascript' implementation */
/** Code for 'javascript' or 'python' implementation */
code?: string;
/** HTTP endpoint for 'http' implementation */
endpoint?: string;

View File

@@ -28,6 +28,16 @@ export interface FileAttachment {
base64Data?: string;
/** Preview thumbnail for images (data URI with prefix for display) */
previewUrl?: string;
/** Whether content was truncated due to size limits */
truncated?: boolean;
/** Original content length before truncation */
originalLength?: number;
/** Original File object for storage (not serializable, transient) */
originalFile?: File;
/** Whether this file was analyzed by the FileAnalyzer agent */
analyzed?: boolean;
/** AI-generated summary from FileAnalyzer (for large/truncated files) */
summary?: string;
}
// ============================================================================
@@ -121,6 +131,16 @@ export const MAX_PDF_SIZE = 10 * 1024 * 1024;
/** Maximum image dimensions (LLaVA limit) */
export const MAX_IMAGE_DIMENSION = 1344;
/** Maximum extracted content length (chars) - prevents context overflow
* 8K chars ≈ 2K tokens per file, allowing ~3 files in an 8K context model
*/
export const MAX_EXTRACTED_CONTENT = 8000;
/** Threshold for auto-analysis of large files (chars)
* Files larger than this would benefit from summarization (future feature)
*/
export const ANALYSIS_THRESHOLD = 8000;
// ============================================================================
// Type Guards
// ============================================================================

View File

@@ -28,6 +28,8 @@ export interface Message {
isSummarized?: boolean;
/** If true, this is a summary message representing compressed conversation history */
isSummary?: boolean;
/** References to attachments stored in IndexedDB */
attachmentIds?: string[];
}
/** A node in the message tree structure (for branching conversations) */

View File

@@ -77,6 +77,37 @@ export const PARAMETER_DESCRIPTIONS: Record<keyof ModelParameters, string> = {
num_ctx: 'Context window size in tokens. Larger uses more memory.'
};
/**
* Auto-compact settings for automatic context management
*/
export interface AutoCompactSettings {
/** Whether auto-compact is enabled */
enabled: boolean;
/** Context usage threshold (percentage) to trigger auto-compact */
threshold: number;
/** Number of recent messages to preserve when compacting */
preserveCount: number;
}
/**
* Default auto-compact settings
*/
export const DEFAULT_AUTO_COMPACT_SETTINGS: AutoCompactSettings = {
enabled: false,
threshold: 70,
preserveCount: 6
};
/**
* Auto-compact parameter ranges for UI
*/
export const AUTO_COMPACT_RANGES = {
threshold: { min: 50, max: 90, step: 5 },
preserveCount: { min: 2, max: 20, step: 1 }
} as const;
/**
* Chat settings including model parameters
*/
@@ -86,6 +117,9 @@ export interface ChatSettings {
/** Custom model parameters (used when useCustomParameters is true) */
modelParameters: ModelParameters;
/** Auto-compact settings for context management */
autoCompact?: AutoCompactSettings;
}
/**
@@ -93,5 +127,6 @@ export interface ChatSettings {
*/
export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
useCustomParameters: false,
modelParameters: { ...DEFAULT_MODEL_PARAMETERS }
modelParameters: { ...DEFAULT_MODEL_PARAMETERS },
autoCompact: { ...DEFAULT_AUTO_COMPACT_SETTINGS }
};

View File

@@ -17,7 +17,8 @@ import {
MAX_IMAGE_SIZE,
MAX_TEXT_SIZE,
MAX_PDF_SIZE,
MAX_IMAGE_DIMENSION
MAX_IMAGE_DIMENSION,
MAX_EXTRACTED_CONTENT
} from '$lib/types/attachment.js';
// ============================================================================
@@ -51,21 +52,74 @@ export function detectFileType(file: File): AttachmentType | null {
return null;
}
// ============================================================================
// Content Truncation
// ============================================================================
/**
* Result of content truncation
*/
interface TruncateResult {
content: string;
truncated: boolean;
originalLength: number;
}
/**
* Truncate content to maximum allowed length
* Tries to truncate at a natural boundary (newline or space)
*/
function truncateContent(content: string, maxLength: number = MAX_EXTRACTED_CONTENT): TruncateResult {
const originalLength = content.length;
if (originalLength <= maxLength) {
return { content, truncated: false, originalLength };
}
// Try to find a natural break point (newline or space) near the limit
let cutPoint = maxLength;
const searchStart = Math.max(0, maxLength - 500);
// Look for last newline before cutoff
const lastNewline = content.lastIndexOf('\n', maxLength);
if (lastNewline > searchStart) {
cutPoint = lastNewline;
} else {
// Look for last space
const lastSpace = content.lastIndexOf(' ', maxLength);
if (lastSpace > searchStart) {
cutPoint = lastSpace;
}
}
const truncatedContent = content.slice(0, cutPoint) +
`\n\n[... content truncated: ${formatFileSize(originalLength)} total, showing first ${formatFileSize(cutPoint)} ...]`;
return {
content: truncatedContent,
truncated: true,
originalLength
};
}
// ============================================================================
// Text File Processing
// ============================================================================
/**
* Read a text file and return its content
* Read a text file and return its content with truncation info
*/
export async function readTextFile(file: File): Promise<string> {
export async function readTextFile(file: File): Promise<TruncateResult> {
if (file.size > MAX_TEXT_SIZE) {
throw new Error(`File too large. Maximum size is ${MAX_TEXT_SIZE / 1024 / 1024}MB`);
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onload = () => {
const rawContent = reader.result as string;
resolve(truncateContent(rawContent));
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
@@ -150,8 +204,18 @@ async function loadPdfJs(): Promise<typeof import('pdfjs-dist')> {
try {
pdfjsLib = await import('pdfjs-dist');
// Set worker source using CDN for reliability
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`;
// Use locally bundled worker (copied to static/ during build)
// Falls back to CDN if local worker isn't available
const localWorkerPath = '/pdf.worker.min.mjs';
const cdnWorkerPath = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`;
// Try local first, with CDN fallback
try {
const response = await fetch(localWorkerPath, { method: 'HEAD' });
pdfjsLib.GlobalWorkerOptions.workerSrc = response.ok ? localWorkerPath : cdnWorkerPath;
} catch {
pdfjsLib.GlobalWorkerOptions.workerSrc = cdnWorkerPath;
}
return pdfjsLib;
} catch (error) {
@@ -160,9 +224,9 @@ async function loadPdfJs(): Promise<typeof import('pdfjs-dist')> {
}
/**
* Extract text content from a PDF file
* Extract text content from a PDF file with error handling and content limits
*/
export async function extractPdfText(file: File): Promise<string> {
export async function extractPdfText(file: File): Promise<TruncateResult> {
if (file.size > MAX_PDF_SIZE) {
throw new Error(`PDF too large. Maximum size is ${MAX_PDF_SIZE / 1024 / 1024}MB`);
}
@@ -173,20 +237,63 @@ export async function extractPdfText(file: File): Promise<string> {
const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
const textParts: string[] = [];
let totalChars = 0;
let stoppedEarly = false;
const failedPages: number[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.filter((item): item is import('pdfjs-dist/types/src/display/api').TextItem =>
'str' in item
)
.map((item) => item.str)
.join(' ');
textParts.push(pageText);
// Stop if we've already collected enough content
if (totalChars >= MAX_EXTRACTED_CONTENT) {
stoppedEarly = true;
break;
}
try {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
// Null check for textContent.items
if (!textContent?.items) {
console.warn(`PDF page ${i}: No text content items`);
failedPages.push(i);
continue;
}
const pageText = textContent.items
.filter((item): item is import('pdfjs-dist/types/src/display/api').TextItem =>
'str' in item && typeof item.str === 'string'
)
.map((item) => item.str)
.join(' ')
.trim();
if (pageText) {
textParts.push(pageText);
totalChars += pageText.length;
}
} catch (pageError) {
console.warn(`PDF page ${i} extraction failed:`, pageError);
failedPages.push(i);
// Continue with other pages instead of failing entirely
}
}
return textParts.join('\n\n');
let rawContent = textParts.join('\n\n');
// Add metadata about extraction issues
const metadata: string[] = [];
if (failedPages.length > 0) {
metadata.push(`[Note: Failed to extract pages: ${failedPages.join(', ')}]`);
}
if (stoppedEarly) {
metadata.push(`[Note: Extraction stopped at page ${textParts.length} of ${pdf.numPages} due to content limit]`);
}
if (metadata.length > 0) {
rawContent = metadata.join('\n') + '\n\n' + rawContent;
}
return truncateContent(rawContent);
}
// ============================================================================
@@ -226,29 +333,36 @@ export async function processFile(file: File): Promise<ProcessFileOutcome> {
attachment: {
...baseAttachment,
base64Data: base64,
previewUrl
previewUrl,
originalFile: file
}
};
}
case 'text': {
const textContent = await readTextFile(file);
const result = await readTextFile(file);
return {
success: true,
attachment: {
...baseAttachment,
textContent
textContent: result.content,
truncated: result.truncated,
originalLength: result.originalLength,
originalFile: file
}
};
}
case 'pdf': {
const textContent = await extractPdfText(file);
const result = await extractPdfText(file);
return {
success: true,
attachment: {
...baseAttachment,
textContent
textContent: result.content,
truncated: result.truncated,
originalLength: result.originalLength,
originalFile: file
}
};
}
@@ -302,11 +416,27 @@ export function getFileIcon(type: AttachmentType): string {
/**
* Format attachment content for inclusion in message
* Prepends file content with a header showing filename
* Uses XML-style tags for cleaner parsing by LLMs
*/
export function formatAttachmentsForMessage(attachments: FileAttachment[]): string {
return attachments
.filter((a) => a.textContent)
.map((a) => `--- ${a.filename} ---\n${a.textContent}`)
.map((a) => {
const truncatedAttr = a.truncated ? ' truncated="true"' : '';
const sizeAttr = ` size="${formatFileSize(a.size)}"`;
return `<file name="${escapeXmlAttr(a.filename)}"${sizeAttr}${truncatedAttr}>\n${a.textContent}\n</file>`;
})
.join('\n\n');
}
/**
* Escape special characters for XML attribute values
*/
function escapeXmlAttr(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

View File

@@ -7,15 +7,19 @@
import { onMount } from 'svelte';
import { chatState, conversationsState, modelsState, toolsState, promptsState } from '$lib/stores';
import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js';
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
import { settingsState } from '$lib/stores/settings.svelte';
import { createConversation as createStoredConversation, addMessage as addStoredMessage, updateConversation } from '$lib/storage';
import { createConversation as createStoredConversation, addMessage as addStoredMessage, updateConversation, saveAttachments } from '$lib/storage';
import { ollamaClient } from '$lib/ollama';
import type { OllamaMessage, OllamaToolDefinition, OllamaToolCall } from '$lib/ollama';
import { getFunctionModel, USE_FUNCTION_MODEL, runToolCalls, formatToolResultsForChat } from '$lib/tools';
import { searchSimilar, formatResultsAsContext, getKnowledgeBaseStats } from '$lib/memory';
import ChatWindow from '$lib/components/chat/ChatWindow.svelte';
import type { Conversation } from '$lib/types/conversation';
import type { FileAttachment } from '$lib/types/attachment.js';
import { fileAnalyzer, analyzeFilesInBatches, formatAnalyzedAttachment, type AnalysisResult } from '$lib/services/fileAnalyzer.js';
import { replaceState } from '$app/navigation';
// RAG state
let ragEnabled = $state(true);
@@ -24,6 +28,10 @@
// Thinking mode state (for reasoning models)
let thinkingEnabled = $state(true);
// File analysis state
let isAnalyzingFiles = $state(false);
let analyzingFileNames = $state<string[]>([]);
// Derived: Check if selected model supports thinking
const supportsThinking = $derived.by(() => {
const caps = modelsState.selectedCapabilities;
@@ -69,7 +77,7 @@
* Handle first message submission
* Creates a new conversation and starts streaming the response
*/
async function handleFirstMessage(content: string, images?: string[]): Promise<void> {
async function handleFirstMessage(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
const model = modelsState.selectedId;
if (!model) {
console.error('No model selected');
@@ -103,21 +111,136 @@
// Set up chat state for the new conversation
chatState.conversationId = conversationId;
// Add user message to tree
// Collect attachment IDs if we have attachments to save
let attachmentIds: string[] | undefined;
if (attachments && attachments.length > 0) {
attachmentIds = attachments.map(a => a.id);
}
// Add user message to tree (including attachmentIds for display)
const userMessageId = chatState.addMessage({
role: 'user',
content,
images
images,
attachmentIds
});
// Save attachments to IndexedDB
if (attachments && attachments.length > 0) {
const files = await Promise.all(attachments.map(async (a) => {
if (a.base64Data) {
const binary = atob(a.base64Data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new File([bytes], a.filename, { type: a.mimeType });
} else if (a.textContent) {
return new File([a.textContent], a.filename, { type: a.mimeType });
} else {
return new File([], a.filename, { type: a.mimeType });
}
}));
const saveResult = await saveAttachments(userMessageId, files, attachments);
if (!saveResult.success) {
console.error('Failed to save attachments:', saveResult.error);
}
}
// Persist user message to IndexedDB with the SAME ID as chatState
await addStoredMessage(conversationId, { role: 'user', content, images }, null, userMessageId);
await addStoredMessage(conversationId, { role: 'user', content, images, attachmentIds }, null, userMessageId);
// Update URL without navigation (keeps ChatWindow mounted)
history.replaceState({}, '', `/chat/${conversationId}`);
// NOTE: URL update moved to onComplete to avoid aborting the stream
// The URL will update after the first response completes
// Start streaming response
const assistantMessageId = chatState.startStreaming();
// Process attachments if any
let contentForOllama = content;
let assistantMessageId: string | null = null;
if (attachments && attachments.length > 0) {
// Show processing indicator - this message will become the assistant response
isAnalyzingFiles = true;
analyzingFileNames = attachments.map(a => a.filename);
assistantMessageId = chatState.startStreaming();
const fileCount = attachments.length;
const fileLabel = fileCount === 1 ? 'file' : 'files';
chatState.setStreamContent(`Processing ${fileCount} ${fileLabel}...`);
try {
// Check if any files need actual LLM analysis
// Force analysis when >3 files to prevent context overflow (max 5 files allowed)
const forceAnalysis = attachments.length > 3;
const filesToAnalyze = forceAnalysis
? attachments.filter(a => a.textContent && a.textContent.length > 2000)
: attachments.filter(a => fileAnalyzer.shouldAnalyze(a));
if (filesToAnalyze.length > 0) {
// Update indicator to show analysis
chatState.setStreamContent(`Analyzing ${filesToAnalyze.length} ${filesToAnalyze.length === 1 ? 'file' : 'files'}...`);
const analysisResults = await analyzeFilesInBatches(filesToAnalyze, model, 3);
// Update attachments with results
filesToAnalyze.forEach((file) => {
const result = analysisResults.get(file.id);
if (result) {
file.analyzed = result.analyzed;
file.summary = result.summary;
}
});
// Build formatted content with file summaries
const formattedParts: string[] = [content];
for (const attachment of attachments) {
const result = analysisResults.get(attachment.id);
if (result) {
formattedParts.push(formatAnalyzedAttachment(attachment, result));
} else if (attachment.textContent) {
formattedParts.push(`<file name="${attachment.filename}">\n${attachment.textContent}\n</file>`);
}
}
contentForOllama = formattedParts.join('\n\n');
} else {
// No files need analysis, format with content
const parts: string[] = [content];
for (const a of attachments) {
if (a.textContent) {
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
}
}
contentForOllama = parts.join('\n\n');
}
// Keep "Processing..." visible - LLM streaming will replace it
} catch (error) {
console.error('[NewChat] File processing failed:', error);
chatState.setStreamContent('Processing failed, proceeding with original content...');
await new Promise(r => setTimeout(r, 1000));
// Fallback: use original content with raw file text
const parts: string[] = [content];
for (const a of attachments) {
if (a.textContent) {
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
}
}
contentForOllama = parts.join('\n\n');
} finally {
isAnalyzingFiles = false;
analyzingFileNames = [];
}
}
// Start streaming response (reuse existing message if processing files)
const hadProcessingMessage = !!assistantMessageId;
if (!assistantMessageId) {
assistantMessageId = chatState.startStreaming();
}
// Track if we need to clear the "Processing..." text on first token
let needsClearOnFirstToken = hadProcessingMessage;
// Start streaming metrics tracking
streamingMetricsState.startStream();
@@ -128,18 +251,17 @@
try {
let messages: OllamaMessage[] = [{
role: 'user',
content,
content: contentForOllama,
images
}];
// Build system prompt from active prompt + RAG context
// Build system prompt from resolution service + RAG context
const systemParts: string[] = [];
// Wait for prompts to be loaded, then add system prompt if active
await promptsState.ready();
const activePrompt = promptsState.activePrompt;
if (activePrompt) {
systemParts.push(activePrompt.content);
// Resolve system prompt using priority chain (model-aware)
const resolvedPrompt = await resolveSystemPrompt(model, null, null);
if (resolvedPrompt.content) {
systemParts.push(resolvedPrompt.content);
}
// Add RAG context if available
@@ -148,6 +270,9 @@
systemParts.push(`You have access to a knowledge base. Use the following relevant context to help answer the user's question. If the context isn't relevant, you can ignore it.\n\n${ragContext}`);
}
// Always add language instruction
systemParts.push('Always respond in the same language the user writes in. Default to English if unclear.');
// Inject combined system message
if (systemParts.length > 0) {
const systemMessage: OllamaMessage = {
@@ -175,6 +300,11 @@
{ model: chatModel, messages, tools, think: useNativeThinking, options: settingsState.apiParameters },
{
onThinkingToken: (token) => {
// Clear "Processing..." on first token
if (needsClearOnFirstToken) {
chatState.setStreamContent('');
needsClearOnFirstToken = false;
}
// Accumulate thinking and update the message
if (!streamingThinking) {
// Start the thinking block
@@ -185,6 +315,11 @@
streamingMetricsState.incrementTokens();
},
onToken: (token) => {
// Clear "Processing..." on first token
if (needsClearOnFirstToken) {
chatState.setStreamContent('');
needsClearOnFirstToken = false;
}
// Close thinking block when content starts
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\n\n');
@@ -232,10 +367,16 @@
// Generate a smarter title in the background (don't await)
generateSmartTitle(conversationId, content, node.message.content);
// Update URL now that streaming is complete
replaceState(`/chat/${conversationId}`, {});
}
},
onError: (error) => {
console.error('Streaming error:', error);
// Show error to user instead of leaving "Processing..."
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
chatState.finishStreaming();
streamingMetricsState.endStream();
}
@@ -243,6 +384,9 @@
);
} catch (error) {
console.error('Failed to send message:', error);
// Show error to user
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
chatState.finishStreaming();
streamingMetricsState.endStream();
}

View File

@@ -11,7 +11,10 @@
import { modelOperationsState } from '$lib/stores/model-operations.svelte';
import { ModelCard } from '$lib/components/models';
import PullModelDialog from '$lib/components/models/PullModelDialog.svelte';
import ModelEditorDialog from '$lib/components/models/ModelEditorDialog.svelte';
import { fetchTagSizes, type RemoteModel } from '$lib/api/model-registry';
import { modelInfoService, type ModelInfo } from '$lib/services/model-info-service';
import type { ModelEditorMode } from '$lib/stores/model-creation.svelte';
// Search debounce
let searchInput = $state('');
@@ -40,12 +43,14 @@
let pullProgress = $state<{ status: string; completed?: number; total?: number } | null>(null);
let pullError = $state<string | null>(null);
let loadingSizes = $state(false);
let capabilitiesVerified = $state(false); // True if capabilities come from Ollama (installed model)
async function handleSelectModel(model: RemoteModel): Promise<void> {
selectedModel = model;
selectedTag = model.tags[0] || '';
pullProgress = null;
pullError = null;
capabilitiesVerified = false;
// Fetch tag sizes if not already loaded
if (!model.tagSizes || Object.keys(model.tagSizes).length === 0) {
@@ -60,6 +65,21 @@
loadingSizes = false;
}
}
// Try to fetch real capabilities from Ollama if model is installed locally
// This overrides scraped capabilities from ollama.com with accurate runtime data
try {
const realCapabilities = await modelsState.fetchCapabilities(model.slug);
// fetchCapabilities returns empty array on error, but we check hasCapability to confirm model exists
if (modelsState.hasCapability(model.slug, 'completion') || realCapabilities.length > 0) {
// Model is installed - use real capabilities from Ollama
selectedModel = { ...selectedModel!, capabilities: realCapabilities };
capabilitiesVerified = true;
}
} catch {
// Model not installed locally - keep scraped capabilities
capabilitiesVerified = false;
}
}
function closeDetails(): void {
@@ -167,6 +187,70 @@
let deleting = $state(false);
let deleteError = $state<string | null>(null);
// Model editor dialog state
let modelEditorOpen = $state(false);
let modelEditorMode = $state<ModelEditorMode>('create');
let editingModelName = $state<string | undefined>(undefined);
let editingSystemPrompt = $state<string | undefined>(undefined);
let editingBaseModel = $state<string | undefined>(undefined);
// Cache for model info (to know which models have embedded prompts)
let modelInfoCache = $state<Map<string, ModelInfo>>(new Map());
function openCreateDialog(): void {
modelEditorMode = 'create';
editingModelName = undefined;
editingSystemPrompt = undefined;
editingBaseModel = undefined;
modelEditorOpen = true;
}
async function openEditDialog(modelName: string): Promise<void> {
// Fetch model info to get the current system prompt and base model
const info = await modelInfoService.getModelInfo(modelName);
if (!info.systemPrompt) {
// No embedded prompt - shouldn't happen if we only show edit for models with prompts
return;
}
// Get base model from family - if a model has an embedded prompt, its parent is typically
// the family base model (e.g., llama3.2). For now, use the model name itself as fallback.
const localModel = localModelsState.models.find((m) => m.name === modelName);
const baseModel = localModel?.family || modelName;
modelEditorMode = 'edit';
editingModelName = modelName;
editingSystemPrompt = info.systemPrompt;
editingBaseModel = baseModel;
modelEditorOpen = true;
}
function closeModelEditor(): void {
modelEditorOpen = false;
// Refresh models list after closing (in case a model was created/updated)
localModelsState.refresh();
}
// Fetch model info for all local models to determine which have embedded prompts
async function fetchModelInfoForLocalModels(): Promise<void> {
const newCache = new Map<string, ModelInfo>();
for (const model of localModelsState.models) {
try {
const info = await modelInfoService.getModelInfo(model.name);
newCache.set(model.name, info);
} catch {
// Ignore errors - model might not be accessible
}
}
modelInfoCache = newCache;
}
// Check if a model has an embedded prompt (and thus can be edited)
function hasEmbeddedPrompt(modelName: string): boolean {
const info = modelInfoCache.get(modelName);
return info?.systemPrompt !== null && info?.systemPrompt !== undefined && info.systemPrompt.length > 0;
}
// Delete a local model
async function deleteModel(modelName: string): Promise<void> {
if (deleting) return;
@@ -214,12 +298,22 @@
}, 300);
}
// Fetch model info when local models change
$effect(() => {
if (localModelsState.models.length > 0) {
fetchModelInfoForLocalModels();
}
});
// Initialize on mount
onMount(() => {
// Initialize stores (backend handles heavy operations)
localModelsState.init();
modelRegistry.init();
modelsState.refresh();
modelsState.refresh().then(() => {
// Fetch capabilities for all installed models
modelsState.fetchAllCapabilities();
});
});
</script>
@@ -265,6 +359,17 @@
{/if}
</button>
{:else}
<!-- Create Custom Model Button -->
<button
type="button"
onclick={openCreateDialog}
class="flex items-center gap-2 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-violet-500"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
<span>Create Custom</span>
</button>
<!-- Pull Model Button -->
<button
type="button"
@@ -476,6 +581,7 @@
{:else}
<div class="space-y-2">
{#each localModelsState.models as model (model.name)}
{@const caps = modelsState.getCapabilities(model.name) ?? []}
<div class="group rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:border-theme-subtle">
<div class="flex items-center justify-between">
<div class="flex-1">
@@ -489,6 +595,11 @@
Update
</span>
{/if}
{#if hasEmbeddedPrompt(model.name)}
<span class="rounded bg-violet-900/50 px-2 py-0.5 text-xs text-violet-300" title="Custom model with embedded system prompt">
Custom
</span>
{/if}
</div>
<div class="mt-1 flex items-center gap-4 text-xs text-theme-muted">
<span>{formatBytes(model.size)}</span>
@@ -496,6 +607,36 @@
<span>Parameters: {model.parameterSize}</span>
<span>Quantization: {model.quantizationLevel}</span>
</div>
<!-- Capabilities (from Ollama runtime - verified) -->
{#if caps.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#if caps.includes('vision')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-purple-900/50 text-purple-300">
<span>👁</span><span>Vision</span>
</span>
{/if}
{#if caps.includes('tools')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-blue-900/50 text-blue-300">
<span>🔧</span><span>Tools</span>
</span>
{/if}
{#if caps.includes('thinking')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-pink-900/50 text-pink-300">
<span>🧠</span><span>Thinking</span>
</span>
{/if}
{#if caps.includes('embedding')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-amber-900/50 text-amber-300">
<span>📊</span><span>Embedding</span>
</span>
{/if}
{#if caps.includes('code')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-emerald-900/50 text-emerald-300">
<span>💻</span><span>Code</span>
</span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2">
{#if deleteConfirm === model.name}
@@ -517,6 +658,18 @@
No
</button>
{:else}
{#if hasEmbeddedPrompt(model.name)}
<button
type="button"
onclick={() => openEditDialog(model.name)}
class="rounded p-2 text-theme-muted opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-violet-400 group-hover:opacity-100"
title="Edit system prompt"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
{/if}
<button
type="button"
onclick={() => deleteConfirm = model.name}
@@ -641,7 +794,7 @@
</div>
<!-- Capability Filters (matches ollama.com capabilities) -->
<div class="mb-6 flex flex-wrap items-center gap-2">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="text-sm text-theme-muted">Capabilities:</span>
<button
type="button"
@@ -694,13 +847,81 @@
<span>Cloud</span>
</button>
{#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.modelType || modelRegistry.searchQuery || modelRegistry.sortBy !== 'pulls_desc'}
<!-- Capability info notice -->
<span class="ml-2 text-xs text-theme-muted" title="Capability data is sourced from ollama.com and may not be accurate. Actual capabilities are verified once a model is installed locally.">
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-3.5 w-3.5 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="opacity-60">from ollama.com</span>
</span>
</div>
<!-- Size Range Filters -->
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="text-sm text-theme-muted">Size:</span>
<button
type="button"
onclick={() => modelRegistry.toggleSizeRange('small')}
class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('small')
? 'bg-emerald-600 text-theme-primary'
: 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
>
≤3B
</button>
<button
type="button"
onclick={() => modelRegistry.toggleSizeRange('medium')}
class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('medium')
? 'bg-emerald-600 text-theme-primary'
: 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
>
4-13B
</button>
<button
type="button"
onclick={() => modelRegistry.toggleSizeRange('large')}
class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('large')
? 'bg-emerald-600 text-theme-primary'
: 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
>
14-70B
</button>
<button
type="button"
onclick={() => modelRegistry.toggleSizeRange('xlarge')}
class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('xlarge')
? 'bg-emerald-600 text-theme-primary'
: 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
>
>70B
</button>
</div>
<!-- Family Filter + Clear All -->
<div class="mb-6 flex flex-wrap items-center gap-4">
{#if modelRegistry.availableFamilies.length > 0}
<div class="flex items-center gap-2">
<span class="text-sm text-theme-muted">Family:</span>
<select
value={modelRegistry.selectedFamily}
onchange={(e) => modelRegistry.setFamily((e.target as HTMLSelectElement).value)}
class="rounded-lg border border-theme bg-theme-secondary px-3 py-1.5 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">All Families</option>
{#each modelRegistry.availableFamilies as family}
<option value={family}>{family}</option>
{/each}
</select>
</div>
{/if}
{#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.selectedSizeRanges.length > 0 || modelRegistry.selectedFamily || modelRegistry.modelType || modelRegistry.searchQuery || modelRegistry.sortBy !== 'pulls_desc'}
<button
type="button"
onclick={() => { modelRegistry.clearFilters(); searchInput = ''; }}
class="ml-2 text-sm text-theme-muted hover:text-theme-primary"
class="text-sm text-theme-muted hover:text-theme-primary"
>
Clear filters
Clear all filters
</button>
{/if}
</div>
@@ -826,14 +1047,40 @@
{/if}
<!-- Capabilities -->
{#if selectedModel.capabilities.length > 0}
{#if selectedModel.capabilities.length > 0 || !capabilitiesVerified}
<div class="mb-6">
<h3 class="mb-2 text-sm font-medium text-theme-secondary">Capabilities</h3>
<div class="flex flex-wrap gap-2">
{#each selectedModel.capabilities as cap}
<span class="rounded bg-theme-tertiary px-2 py-1 text-xs text-theme-secondary">{cap}</span>
{/each}
</div>
<h3 class="mb-2 flex items-center gap-2 text-sm font-medium text-theme-secondary">
<span>Capabilities</span>
{#if capabilitiesVerified}
<span class="inline-flex items-center gap-1 rounded bg-green-900/30 px-1.5 py-0.5 text-xs text-green-400" title="Capabilities verified from installed model">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
verified
</span>
{:else}
<span class="inline-flex items-center gap-1 rounded bg-amber-900/30 px-1.5 py-0.5 text-xs text-amber-400" title="Capabilities sourced from ollama.com - install model for verified data">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
unverified
</span>
{/if}
</h3>
{#if selectedModel.capabilities.length > 0}
<div class="flex flex-wrap gap-2">
{#each selectedModel.capabilities as cap}
<span class="rounded bg-theme-tertiary px-2 py-1 text-xs text-theme-secondary">{cap}</span>
{/each}
</div>
{:else}
<p class="text-xs text-theme-muted">No capabilities reported</p>
{/if}
{#if !capabilitiesVerified}
<p class="mt-2 text-xs text-theme-muted">
Install model to verify actual capabilities
</p>
{/if}
</div>
{/if}
@@ -1031,6 +1278,16 @@
<!-- Pull Model Dialog -->
<PullModelDialog />
<!-- Model Editor Dialog (Create/Edit) -->
<ModelEditorDialog
isOpen={modelEditorOpen}
mode={modelEditorMode}
editingModel={editingModelName}
currentSystemPrompt={editingSystemPrompt}
baseModel={editingBaseModel}
onClose={closeModelEditor}
/>
<!-- Active Pulls Progress (fixed bottom bar) -->
{#if modelOperationsState.activePulls.size > 0}
<div class="fixed bottom-0 left-0 right-0 z-40 border-t border-theme bg-theme-secondary/95 p-4 backdrop-blur-sm">

View File

@@ -5,6 +5,17 @@
*/
import { promptsState, type Prompt } from '$lib/stores';
import {
getAllPromptTemplates,
getPromptCategories,
categoryInfo,
type PromptTemplate,
type PromptCategory
} from '$lib/prompts/templates';
// Tab state
type Tab = 'my-prompts' | 'browse-templates';
let activeTab = $state<Tab>('my-prompts');
// Editor state
let showEditor = $state(false);
@@ -15,14 +26,40 @@
let formDescription = $state('');
let formContent = $state('');
let formIsDefault = $state(false);
let formTargetCapabilities = $state<string[]>([]);
let isSaving = $state(false);
// Template browser state
let selectedCategory = $state<PromptCategory | 'all'>('all');
let previewTemplate = $state<PromptTemplate | null>(null);
let addingTemplateId = $state<string | null>(null);
// Get templates and categories
const templates = getAllPromptTemplates();
const categories = getPromptCategories();
// Filtered templates
const filteredTemplates = $derived(
selectedCategory === 'all'
? templates
: templates.filter((t) => t.category === selectedCategory)
);
// Available capabilities for targeting
const CAPABILITIES = [
{ id: 'code', label: 'Code', description: 'Auto-use with coding models' },
{ id: 'vision', label: 'Vision', description: 'Auto-use with vision models' },
{ id: 'thinking', label: 'Thinking', description: 'Auto-use with reasoning models' },
{ id: 'tools', label: 'Tools', description: 'Auto-use with tool-capable models' }
] as const;
function openCreateEditor(): void {
editingPrompt = null;
formName = '';
formDescription = '';
formContent = '';
formIsDefault = false;
formTargetCapabilities = [];
showEditor = true;
}
@@ -32,6 +69,7 @@
formDescription = prompt.description;
formContent = prompt.content;
formIsDefault = prompt.isDefault;
formTargetCapabilities = prompt.targetCapabilities ?? [];
showEditor = true;
}
@@ -45,19 +83,22 @@
isSaving = true;
try {
const capabilities = formTargetCapabilities.length > 0 ? formTargetCapabilities : undefined;
if (editingPrompt) {
await promptsState.update(editingPrompt.id, {
name: formName.trim(),
description: formDescription.trim(),
content: formContent,
isDefault: formIsDefault
isDefault: formIsDefault,
targetCapabilities: capabilities ?? []
});
} else {
await promptsState.add({
name: formName.trim(),
description: formDescription.trim(),
content: formContent,
isDefault: formIsDefault
isDefault: formIsDefault,
targetCapabilities: capabilities
});
}
closeEditor();
@@ -66,6 +107,14 @@
}
}
function toggleCapability(capId: string): void {
if (formTargetCapabilities.includes(capId)) {
formTargetCapabilities = formTargetCapabilities.filter((c) => c !== capId);
} else {
formTargetCapabilities = [...formTargetCapabilities, capId];
}
}
async function handleDelete(prompt: Prompt): Promise<void> {
if (confirm(`Delete "${prompt.name}"? This cannot be undone.`)) {
await promptsState.remove(prompt.id);
@@ -88,6 +137,23 @@
}
}
async function addTemplateToLibrary(template: PromptTemplate): Promise<void> {
addingTemplateId = template.id;
try {
await promptsState.add({
name: template.name,
description: template.description,
content: template.content,
isDefault: false,
targetCapabilities: template.targetCapabilities
});
// Switch to My Prompts tab to show the new prompt
activeTab = 'my-prompts';
} finally {
addingTemplateId = null;
}
}
// Format date for display
function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
@@ -101,7 +167,7 @@
<div class="h-full overflow-y-auto bg-theme-primary p-6">
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-theme-primary">System Prompts</h1>
<p class="mt-1 text-sm text-theme-muted">
@@ -109,160 +175,449 @@
</p>
</div>
<button
type="button"
onclick={openCreateEditor}
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create Prompt
</button>
</div>
<!-- Active prompt indicator -->
{#if promptsState.activePrompt}
<div class="mb-6 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
<div class="flex items-center gap-2 text-sm text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Active system prompt for new chats: <strong class="text-blue-300">{promptsState.activePrompt.name}</strong></span>
</div>
</div>
{/if}
<!-- Prompts list -->
{#if promptsState.isLoading}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-theme-subtle border-t-blue-500"></div>
</div>
{:else if promptsState.prompts.length === 0}
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<h3 class="mt-4 text-sm font-medium text-theme-muted">No system prompts yet</h3>
<p class="mt-1 text-sm text-theme-muted">
Create a system prompt to customize AI behavior
</p>
{#if activeTab === 'my-prompts'}
<button
type="button"
onclick={openCreateEditor}
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-theme-tertiary px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-theme-tertiary"
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create your first prompt
Create Prompt
</button>
</div>
{:else}
<div class="space-y-3">
{#each promptsState.prompts as prompt (prompt.id)}
<div
class="rounded-lg border bg-theme-secondary p-4 transition-colors {promptsState.activePromptId === prompt.id ? 'border-blue-500/50' : 'border-theme'}"
{/if}
</div>
<!-- Tabs -->
<div class="mb-6 flex gap-1 rounded-lg bg-theme-tertiary p-1">
<button
type="button"
onclick={() => (activeTab = 'my-prompts')}
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'my-prompts'
? 'bg-theme-secondary text-theme-primary shadow'
: 'text-theme-muted hover:text-theme-secondary'}"
>
My Prompts
{#if promptsState.prompts.length > 0}
<span
class="ml-1.5 rounded-full bg-theme-tertiary px-2 py-0.5 text-xs {activeTab ===
'my-prompts'
? 'bg-blue-500/20 text-blue-400'
: ''}"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="font-medium text-theme-primary">{prompt.name}</h3>
{#if prompt.isDefault}
<span class="rounded bg-blue-900 px-2 py-0.5 text-xs text-blue-300">
default
</span>
{/if}
{#if promptsState.activePromptId === prompt.id}
<span class="rounded bg-emerald-900 px-2 py-0.5 text-xs text-emerald-300">
active
</span>
{promptsState.prompts.length}
</span>
{/if}
</button>
<button
type="button"
onclick={() => (activeTab = 'browse-templates')}
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'browse-templates'
? 'bg-theme-secondary text-theme-primary shadow'
: 'text-theme-muted hover:text-theme-secondary'}"
>
Browse Templates
<span
class="ml-1.5 rounded-full bg-theme-tertiary px-2 py-0.5 text-xs {activeTab ===
'browse-templates'
? 'bg-purple-500/20 text-purple-400'
: ''}"
>
{templates.length}
</span>
</button>
</div>
<!-- My Prompts Tab -->
{#if activeTab === 'my-prompts'}
<!-- Active prompt indicator -->
{#if promptsState.activePrompt}
<div class="mb-6 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
<div class="flex items-center gap-2 text-sm text-blue-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
>Active system prompt for new chats: <strong class="text-blue-300"
>{promptsState.activePrompt.name}</strong
></span
>
</div>
</div>
{/if}
<!-- Prompts list -->
{#if promptsState.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-theme-subtle border-t-blue-500"
></div>
</div>
{:else if promptsState.prompts.length === 0}
<div
class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mx-auto h-12 w-12 text-theme-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
<h3 class="mt-4 text-sm font-medium text-theme-muted">No system prompts yet</h3>
<p class="mt-1 text-sm text-theme-muted">
Create a prompt or browse templates to get started
</p>
<div class="mt-4 flex justify-center gap-3">
<button
type="button"
onclick={openCreateEditor}
class="inline-flex items-center gap-2 rounded-lg bg-theme-tertiary px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-theme-tertiary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create from scratch
</button>
<button
type="button"
onclick={() => (activeTab = 'browse-templates')}
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-purple-700"
>
Browse templates
</button>
</div>
</div>
{:else}
<div class="space-y-3">
{#each promptsState.prompts as prompt (prompt.id)}
<div
class="rounded-lg border bg-theme-secondary p-4 transition-colors {promptsState.activePromptId ===
prompt.id
? 'border-blue-500/50'
: 'border-theme'}"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium text-theme-primary">{prompt.name}</h3>
{#if prompt.isDefault}
<span class="rounded bg-blue-900 px-2 py-0.5 text-xs text-blue-300">
default
</span>
{/if}
{#if promptsState.activePromptId === prompt.id}
<span class="rounded bg-emerald-900 px-2 py-0.5 text-xs text-emerald-300">
active
</span>
{/if}
{#if prompt.targetCapabilities && prompt.targetCapabilities.length > 0}
{#each prompt.targetCapabilities as cap (cap)}
<span class="rounded bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">
{cap}
</span>
{/each}
{/if}
</div>
{#if prompt.description}
<p class="mt-1 text-sm text-theme-muted">{prompt.description}</p>
{/if}
<p class="mt-2 line-clamp-2 text-sm text-theme-muted">
{prompt.content}
</p>
<p class="mt-2 text-xs text-theme-muted">
Updated {formatDate(prompt.updatedAt)}
</p>
</div>
{#if prompt.description}
<p class="mt-1 text-sm text-theme-muted">{prompt.description}</p>
{/if}
<p class="mt-2 line-clamp-2 text-sm text-theme-muted">
{prompt.content}
</p>
<p class="mt-2 text-xs text-theme-muted">
Updated {formatDate(prompt.updatedAt)}
</p>
</div>
<div class="flex items-center gap-2">
<!-- Use/Active toggle -->
<button
type="button"
onclick={() => handleSetActive(prompt)}
class="rounded p-1.5 transition-colors {promptsState.activePromptId === prompt.id ? 'bg-emerald-600 text-theme-primary' : 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
title={promptsState.activePromptId === prompt.id ? 'Deactivate' : 'Use for new chats'}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<div class="flex items-center gap-2">
<!-- Use/Active toggle -->
<button
type="button"
onclick={() => handleSetActive(prompt)}
class="rounded p-1.5 transition-colors {promptsState.activePromptId === prompt.id
? 'bg-emerald-600 text-theme-primary'
: 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
title={promptsState.activePromptId === prompt.id
? 'Deactivate'
: 'Use for new chats'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Set as default -->
<button
type="button"
onclick={() => handleSetDefault(prompt)}
class="rounded p-1.5 transition-colors {prompt.isDefault ? 'bg-blue-600 text-theme-primary' : 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
title={prompt.isDefault ? 'Remove as default' : 'Set as default'}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill={prompt.isDefault ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<!-- Set as default -->
<button
type="button"
onclick={() => handleSetDefault(prompt)}
class="rounded p-1.5 transition-colors {prompt.isDefault
? 'bg-blue-600 text-theme-primary'
: 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
title={prompt.isDefault ? 'Remove as default' : 'Set as default'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill={prompt.isDefault ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
</button>
<!-- Edit -->
<button
type="button"
onclick={() => openEditEditor(prompt)}
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
title="Edit"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<!-- Edit -->
<button
type="button"
onclick={() => openEditEditor(prompt)}
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
title="Edit"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<!-- Delete -->
<button
type="button"
onclick={() => handleDelete(prompt)}
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
title="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<!-- Delete -->
<button
type="button"
onclick={() => handleDelete(prompt)}
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
title="Delete"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
<!-- Info section -->
<section class="mt-8 rounded-lg border border-theme bg-theme-secondary/50 p-4">
<h3 class="flex items-center gap-2 text-sm font-medium text-theme-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
How System Prompts Work
</h3>
<p class="mt-2 text-sm text-theme-muted">
System prompts define the AI's behavior, personality, and constraints. They're sent at
the beginning of each conversation to set the context. Use them to create specialized
assistants (e.g., code reviewer, writing helper) or to enforce specific response formats.
</p>
<p class="mt-2 text-sm text-theme-muted">
<strong class="text-theme-secondary">Default prompt:</strong> Used for all new chats unless
overridden.
<strong class="text-theme-secondary">Active prompt:</strong> Currently selected for your session.
<strong class="text-theme-secondary">Capability targeting:</strong> Auto-matches prompts to
models with specific capabilities (code, vision, thinking, tools).
</p>
</section>
{/if}
<!-- Browse Templates Tab -->
{#if activeTab === 'browse-templates'}
<!-- Category filter -->
<div class="mb-6 flex flex-wrap gap-2">
<button
type="button"
onclick={() => (selectedCategory = 'all')}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
'all'
? 'bg-theme-secondary text-theme-primary'
: 'bg-theme-tertiary text-theme-muted hover:text-theme-secondary'}"
>
All
</button>
{#each categories as category (category)}
{@const info = categoryInfo[category]}
<button
type="button"
onclick={() => (selectedCategory = category)}
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
category
? info.color
: 'bg-theme-tertiary text-theme-muted hover:text-theme-secondary'}"
>
<span>{info.icon}</span>
{info.label}
</button>
{/each}
</div>
<!-- Templates grid -->
<div class="grid gap-4 sm:grid-cols-2">
{#each filteredTemplates as template (template.id)}
{@const info = categoryInfo[template.category]}
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<h3 class="font-medium text-theme-primary">{template.name}</h3>
<span class="mt-1 inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
<span>{info.icon}</span>
{info.label}
</span>
</div>
<button
type="button"
onclick={() => addTemplateToLibrary(template)}
disabled={addingTemplateId === template.id}
class="flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{#if addingTemplateId === template.id}
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
{/if}
Add
</button>
</div>
<p class="text-sm text-theme-muted">{template.description}</p>
<button
type="button"
onclick={() => (previewTemplate = template)}
class="mt-3 text-sm text-blue-400 hover:text-blue-300"
>
Preview prompt
</button>
</div>
{/each}
</div>
{/if}
<!-- Info section -->
<section class="mt-8 rounded-lg border border-theme bg-theme-secondary/50 p-4">
<h3 class="flex items-center gap-2 text-sm font-medium text-theme-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
How System Prompts Work
</h3>
<p class="mt-2 text-sm text-theme-muted">
System prompts define the AI's behavior, personality, and constraints. They're sent at the
beginning of each conversation to set the context. Use them to create specialized assistants
(e.g., code reviewer, writing helper) or to enforce specific response formats.
</p>
<p class="mt-2 text-sm text-theme-muted">
<strong class="text-theme-secondary">Default prompt:</strong> Automatically used for all new chats.
<strong class="text-theme-secondary">Active prompt:</strong> Currently selected for your session.
</p>
</section>
<!-- Info about templates -->
<section class="mt-8 rounded-lg border border-theme bg-theme-secondary/50 p-4">
<h3 class="flex items-center gap-2 text-sm font-medium text-theme-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-purple-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"
/>
</svg>
About Templates
</h3>
<p class="mt-2 text-sm text-theme-muted">
These curated templates are designed for common use cases. When you add a template, it
creates a copy in your library that you can customize. Templates with capability tags
will auto-match with compatible models.
</p>
</section>
{/if}
</div>
</div>
@@ -270,8 +625,12 @@
{#if showEditor}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => { if (e.target === e.currentTarget) closeEditor(); }}
onkeydown={(e) => { if (e.key === 'Escape') closeEditor(); }}
onclick={(e) => {
if (e.target === e.currentTarget) closeEditor();
}}
onkeydown={(e) => {
if (e.key === 'Escape') closeEditor();
}}
role="dialog"
aria-modal="true"
aria-labelledby="editor-title"
@@ -286,13 +645,26 @@
onclick={closeEditor}
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }} class="p-6">
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
class="p-6"
>
<div class="space-y-4">
<!-- Name -->
<div>
@@ -311,7 +683,10 @@
<!-- Description -->
<div>
<label for="prompt-description" class="mb-1 block text-sm font-medium text-theme-secondary">
<label
for="prompt-description"
class="mb-1 block text-sm font-medium text-theme-secondary"
>
Description
</label>
<input
@@ -353,6 +728,33 @@
Set as default for new chats
</label>
</div>
<!-- Capability targeting -->
<div>
<label class="mb-2 block text-sm font-medium text-theme-secondary">
Auto-use for model types
</label>
<p class="mb-3 text-xs text-theme-muted">
When a model has these capabilities and no other prompt is selected, this prompt will
be used automatically.
</p>
<div class="flex flex-wrap gap-2">
{#each CAPABILITIES as cap (cap.id)}
<button
type="button"
onclick={() => toggleCapability(cap.id)}
class="rounded-lg border px-3 py-1.5 text-sm transition-colors {formTargetCapabilities.includes(
cap.id
)
? 'border-blue-500 bg-blue-500/20 text-blue-300'
: 'border-theme-subtle bg-theme-tertiary text-theme-muted hover:border-theme hover:text-theme-secondary'}"
title={cap.description}
>
{cap.label}
</button>
{/each}
</div>
</div>
</div>
<!-- Actions -->
@@ -376,3 +778,94 @@
</div>
</div>
{/if}
<!-- Template Preview Modal -->
{#if previewTemplate}
{@const info = categoryInfo[previewTemplate.category]}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => {
if (e.target === e.currentTarget) previewTemplate = null;
}}
onkeydown={(e) => {
if (e.key === 'Escape') previewTemplate = null;
}}
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-xl bg-theme-secondary shadow-xl">
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<div>
<h2 class="text-lg font-semibold text-theme-primary">{previewTemplate.name}</h2>
<div class="mt-1 flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
<span>{info.icon}</span>
{info.label}
</span>
{#if previewTemplate.targetCapabilities}
{#each previewTemplate.targetCapabilities as cap}
<span class="rounded bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">
{cap}
</span>
{/each}
{/if}
</div>
</div>
<button
type="button"
onclick={() => (previewTemplate = null)}
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-6">
<p class="mb-4 text-sm text-theme-muted">{previewTemplate.description}</p>
<pre
class="whitespace-pre-wrap rounded-lg bg-theme-tertiary p-4 font-mono text-sm text-theme-primary">{previewTemplate.content}</pre>
</div>
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
<button
type="button"
onclick={() => (previewTemplate = null)}
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary transition-colors hover:bg-theme-tertiary"
>
Close
</button>
<button
type="button"
onclick={() => {
if (previewTemplate) {
addTemplateToLibrary(previewTemplate);
previewTemplate = null;
}
}}
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add to Library
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,492 @@
<script lang="ts">
/**
* Settings page
* Comprehensive settings for appearance, models, memory, and more
*/
import { onMount } from 'svelte';
import { modelsState, uiState, settingsState, promptsState } from '$lib/stores';
import { modelPromptMappingsState } from '$lib/stores/model-prompt-mappings.svelte.js';
import { modelInfoService, type ModelInfo } from '$lib/services/model-info-service.js';
import { getPrimaryModifierDisplay } from '$lib/utils';
import { PARAMETER_RANGES, PARAMETER_LABELS, PARAMETER_DESCRIPTIONS, AUTO_COMPACT_RANGES } from '$lib/types/settings';
const modifierKey = getPrimaryModifierDisplay();
// Model info cache for the settings page
let modelInfoCache = $state<Map<string, ModelInfo>>(new Map());
let isLoadingModelInfo = $state(false);
// Load model info for all available models
onMount(async () => {
isLoadingModelInfo = true;
try {
const models = modelsState.chatModels;
const infos = await Promise.all(
models.map(async (model) => {
const info = await modelInfoService.getModelInfo(model.name);
return [model.name, info] as [string, ModelInfo];
})
);
modelInfoCache = new Map(infos);
} finally {
isLoadingModelInfo = false;
}
});
// Handle prompt selection for a model
async function handleModelPromptChange(modelName: string, promptId: string | null): Promise<void> {
await modelPromptMappingsState.setMapping(modelName, promptId);
}
// Get the currently mapped prompt ID for a model
function getMappedPromptId(modelName: string): string | null {
return modelPromptMappingsState.getMapping(modelName);
}
// Local state for default model selection
let defaultModel = $state<string | null>(modelsState.selectedId);
// Save default model when it changes
function handleModelChange(): void {
if (defaultModel) {
modelsState.select(defaultModel);
}
}
// Get current model defaults for reset functionality
const currentModelDefaults = $derived(
modelsState.selectedId ? modelsState.getModelDefaults(modelsState.selectedId) : undefined
);
</script>
<div class="h-full overflow-y-auto bg-theme-primary p-6">
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-theme-primary">Settings</h1>
<p class="mt-1 text-sm text-theme-muted">
Configure appearance, model defaults, and behavior
</p>
</div>
<!-- Appearance Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Appearance
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Dark Mode Toggle -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Dark Mode</p>
<p class="text-xs text-theme-muted">Toggle between light and dark theme</p>
</div>
<button
type="button"
onclick={() => uiState.toggleDarkMode()}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-theme {uiState.darkMode ? 'bg-purple-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={uiState.darkMode}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {uiState.darkMode ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
<!-- System Theme Sync -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Use System Theme</p>
<p class="text-xs text-theme-muted">Match your OS light/dark preference</p>
</div>
<button
type="button"
onclick={() => uiState.useSystemTheme()}
class="rounded-lg bg-theme-tertiary px-3 py-1.5 text-xs font-medium text-theme-secondary transition-colors hover:bg-theme-hover"
>
Sync with System
</button>
</div>
</div>
</section>
<!-- Chat Defaults Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Chat Defaults
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div>
<label for="default-model" class="text-sm font-medium text-theme-secondary">Default Model</label>
<p class="text-xs text-theme-muted mb-2">Model used for new conversations</p>
<select
id="default-model"
bind:value={defaultModel}
onchange={handleModelChange}
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-secondary focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
>
{#each modelsState.chatModels as model}
<option value={model.name}>{model.name}</option>
{/each}
</select>
</div>
</div>
</section>
<!-- Model-Prompt Defaults Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
Model-Prompt Defaults
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted mb-4">
Set default system prompts for specific models. When no other prompt is selected, the model's default will be used automatically.
</p>
{#if isLoadingModelInfo}
<div class="flex items-center justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-theme-subtle border-t-violet-500"></div>
<span class="ml-2 text-sm text-theme-muted">Loading model info...</span>
</div>
{:else if modelsState.chatModels.length === 0}
<p class="text-sm text-theme-muted py-4 text-center">
No models available. Make sure Ollama is running.
</p>
{:else}
<div class="space-y-3">
{#each modelsState.chatModels as model (model.name)}
{@const modelInfo = modelInfoCache.get(model.name)}
{@const mappedPromptId = getMappedPromptId(model.name)}
<div class="rounded-lg border border-theme-subtle bg-theme-tertiary p-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-theme-primary text-sm">{model.name}</span>
{#if modelInfo?.capabilities && modelInfo.capabilities.length > 0}
{#each modelInfo.capabilities as cap (cap)}
<span class="rounded bg-violet-900/50 px-1.5 py-0.5 text-xs text-violet-300">
{cap}
</span>
{/each}
{/if}
{#if modelInfo?.systemPrompt}
<span class="rounded bg-amber-900/50 px-1.5 py-0.5 text-xs text-amber-300" title="This model has a built-in system prompt">
embedded
</span>
{/if}
</div>
</div>
<select
value={mappedPromptId ?? ''}
onchange={(e) => {
const value = e.currentTarget.value;
handleModelPromptChange(model.name, value === '' ? null : value);
}}
class="rounded-lg border border-theme-subtle bg-theme-secondary px-2 py-1 text-sm text-theme-secondary focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value="">
{modelInfo?.systemPrompt ? 'Use embedded prompt' : 'No default'}
</option>
{#each promptsState.prompts as prompt (prompt.id)}
<option value={prompt.id}>{prompt.name}</option>
{/each}
</select>
</div>
{#if modelInfo?.systemPrompt}
<p class="mt-2 text-xs text-theme-muted line-clamp-2">
<span class="font-medium text-amber-400">Embedded:</span> {modelInfo.systemPrompt}
</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</section>
<!-- Model Parameters Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
Model Parameters
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Use Custom Parameters Toggle -->
<div class="flex items-center justify-between pb-4 border-b border-theme">
<div>
<p class="text-sm font-medium text-theme-secondary">Use Custom Parameters</p>
<p class="text-xs text-theme-muted">Override model defaults with custom values</p>
</div>
<button
type="button"
onclick={() => settingsState.toggleCustomParameters(currentModelDefaults)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.useCustomParameters ? 'bg-orange-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={settingsState.useCustomParameters}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.useCustomParameters ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
{#if settingsState.useCustomParameters}
<!-- Temperature -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="temperature" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.temperature}</label>
<span class="text-sm text-theme-muted">{settingsState.temperature.toFixed(2)}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.temperature}</p>
<input
id="temperature"
type="range"
min={PARAMETER_RANGES.temperature.min}
max={PARAMETER_RANGES.temperature.max}
step={PARAMETER_RANGES.temperature.step}
value={settingsState.temperature}
oninput={(e) => settingsState.updateParameter('temperature', parseFloat(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Top K -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="top_k" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.top_k}</label>
<span class="text-sm text-theme-muted">{settingsState.top_k}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.top_k}</p>
<input
id="top_k"
type="range"
min={PARAMETER_RANGES.top_k.min}
max={PARAMETER_RANGES.top_k.max}
step={PARAMETER_RANGES.top_k.step}
value={settingsState.top_k}
oninput={(e) => settingsState.updateParameter('top_k', parseInt(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Top P -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="top_p" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.top_p}</label>
<span class="text-sm text-theme-muted">{settingsState.top_p.toFixed(2)}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.top_p}</p>
<input
id="top_p"
type="range"
min={PARAMETER_RANGES.top_p.min}
max={PARAMETER_RANGES.top_p.max}
step={PARAMETER_RANGES.top_p.step}
value={settingsState.top_p}
oninput={(e) => settingsState.updateParameter('top_p', parseFloat(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Context Length -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="num_ctx" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.num_ctx}</label>
<span class="text-sm text-theme-muted">{settingsState.num_ctx.toLocaleString()}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.num_ctx}</p>
<input
id="num_ctx"
type="range"
min={PARAMETER_RANGES.num_ctx.min}
max={PARAMETER_RANGES.num_ctx.max}
step={PARAMETER_RANGES.num_ctx.step}
value={settingsState.num_ctx}
oninput={(e) => settingsState.updateParameter('num_ctx', parseInt(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Reset Button -->
<div class="pt-2">
<button
type="button"
onclick={() => settingsState.resetToDefaults(currentModelDefaults)}
class="text-sm text-orange-400 hover:text-orange-300 transition-colors"
>
Reset to model defaults
</button>
</div>
{:else}
<p class="text-sm text-theme-muted py-2">
Using model defaults. Enable custom parameters to adjust temperature, sampling, and context length.
</p>
{/if}
</div>
</section>
<!-- Memory Management Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
Memory Management
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Auto-Compact Toggle -->
<div class="flex items-center justify-between pb-4 border-b border-theme">
<div>
<p class="text-sm font-medium text-theme-secondary">Auto-Compact</p>
<p class="text-xs text-theme-muted">Automatically summarize older messages when context usage is high</p>
</div>
<button
type="button"
onclick={() => settingsState.toggleAutoCompact()}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.autoCompactEnabled ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={settingsState.autoCompactEnabled}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.autoCompactEnabled ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
{#if settingsState.autoCompactEnabled}
<!-- Threshold Slider -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="compact-threshold" class="text-sm font-medium text-theme-secondary">Context Threshold</label>
<span class="text-sm text-theme-muted">{settingsState.autoCompactThreshold}%</span>
</div>
<p class="text-xs text-theme-muted mb-2">Trigger compaction when context usage exceeds this percentage</p>
<input
id="compact-threshold"
type="range"
min={AUTO_COMPACT_RANGES.threshold.min}
max={AUTO_COMPACT_RANGES.threshold.max}
step={AUTO_COMPACT_RANGES.threshold.step}
value={settingsState.autoCompactThreshold}
oninput={(e) => settingsState.updateAutoCompactThreshold(parseInt(e.currentTarget.value))}
class="w-full accent-emerald-500"
/>
<div class="flex justify-between text-xs text-theme-muted mt-1">
<span>{AUTO_COMPACT_RANGES.threshold.min}%</span>
<span>{AUTO_COMPACT_RANGES.threshold.max}%</span>
</div>
</div>
<!-- Preserve Count -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="preserve-count" class="text-sm font-medium text-theme-secondary">Messages to Preserve</label>
<span class="text-sm text-theme-muted">{settingsState.autoCompactPreserveCount}</span>
</div>
<p class="text-xs text-theme-muted mb-2">Number of recent messages to keep intact (not summarized)</p>
<input
id="preserve-count"
type="range"
min={AUTO_COMPACT_RANGES.preserveCount.min}
max={AUTO_COMPACT_RANGES.preserveCount.max}
step={AUTO_COMPACT_RANGES.preserveCount.step}
value={settingsState.autoCompactPreserveCount}
oninput={(e) => settingsState.updateAutoCompactPreserveCount(parseInt(e.currentTarget.value))}
class="w-full accent-emerald-500"
/>
<div class="flex justify-between text-xs text-theme-muted mt-1">
<span>{AUTO_COMPACT_RANGES.preserveCount.min}</span>
<span>{AUTO_COMPACT_RANGES.preserveCount.max}</span>
</div>
</div>
{:else}
<p class="text-sm text-theme-muted py-2">
Enable auto-compact to automatically manage context usage. When enabled, older messages
will be summarized when context usage exceeds your threshold.
</p>
{/if}
</div>
</section>
<!-- Keyboard Shortcuts Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Keyboard Shortcuts
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">New Chat</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+N</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Search</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+K</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Toggle Sidebar</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+B</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Send Message</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Enter</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">New Line</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Shift+Enter</kbd>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
About
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="flex items-center gap-4">
<div class="rounded-lg bg-theme-tertiary p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
</div>
<div>
<h3 class="font-semibold text-theme-primary">Vessel</h3>
<p class="text-sm text-theme-muted">
A modern interface for local AI with chat, tools, and memory management.
</p>
</div>
</div>
</div>
</section>
</div>
</div>

View File

@@ -4,11 +4,12 @@ import { defineConfig } from 'vite';
// Use environment variable or default to localhost (works with host network mode)
const ollamaUrl = process.env.OLLAMA_API_URL || 'http://localhost:11434';
const backendUrl = process.env.BACKEND_URL || 'http://localhost:9090';
const devPort = parseInt(process.env.DEV_PORT || '7842', 10);
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 7842,
port: devPort,
proxy: {
// Backend health check
'/health': {