Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e1dc842a | |||
| c2136fc06a | |||
| 245526af99 | |||
| 949802e935 | |||
| ddce578833 | |||
| 976b4cd84b | |||
| 73279c7e60 | |||
| 3513215ef5 | |||
| 5466ef29da | |||
| a0d1d4f114 | |||
| 229c3cc242 | |||
| 07d3e07fd6 | |||
| 298fb9681e | |||
| 5e6994f415 | |||
| 080deb756b | |||
| 6ec56e3e11 | |||
| 335c827ac8 | |||
| 79f9254cf5 | |||
| 51b89309e6 | |||
| 566273415f | |||
| ab5025694f | |||
| 7adf5922ba | |||
| b656859b10 | |||
| d9b009ce0a | |||
| c048b1343d | |||
| 558c035b84 | |||
| f8fb5ce172 | |||
| 4084c9a361 | |||
| 26b58fbd50 | |||
| 3a4aabff1d | |||
| d94d5ba03a | |||
| 75770b1bd8 | |||
| edd7c94507 | |||
| 6868027a1c | |||
| 1063bec248 | |||
| cf4981f3b2 | |||
| 7cc0df2c78 | |||
| e19b6330e9 | |||
| c194a4e0e9 | |||
| 04c3018360 | |||
| 2699f1cd5c |
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -17,11 +17,30 @@ jobs:
|
||||
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:
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
See the [full release notes on Gitea](https://somegit.dev/vikingowl/vessel/releases) for detailed information.
|
||||
body: ${{ steps.gitea_notes.outputs.notes }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -41,3 +41,7 @@ CLAUDE.md
|
||||
dev.env
|
||||
backend/vessel-backend
|
||||
data/
|
||||
backend/data-dev/
|
||||
|
||||
# Generated files
|
||||
frontend/static/pdf.worker.min.mjs
|
||||
|
||||
426
README.md
426
README.md
@@ -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>
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// Version is set at build time via -ldflags, or defaults to dev
|
||||
var Version = "0.4.6"
|
||||
var Version = "0.5.0"
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
|
||||
@@ -430,7 +430,7 @@ func (f *Fetcher) fetchWithCurl(ctx context.Context, url string, curlPath string
|
||||
"--max-time", fmt.Sprintf("%d", int(opts.Timeout.Seconds())),
|
||||
"-A", opts.UserAgent, // User agent
|
||||
"-w", "\n---CURL_INFO---\n%{content_type}\n%{url_effective}\n%{http_code}", // Output metadata
|
||||
"--compressed", // Accept compressed responses
|
||||
"--compressed", // Automatically decompress responses
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
@@ -439,9 +439,12 @@ func (f *Fetcher) fetchWithCurl(ctx context.Context, url string, curlPath string
|
||||
}
|
||||
|
||||
// Add common headers for better compatibility
|
||||
// Override Accept-Encoding to only include widely-supported formats
|
||||
// This prevents errors when servers return zstd/br that curl may not support
|
||||
args = append(args,
|
||||
"-H", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"-H", "Accept-Language: en-US,en;q=0.5",
|
||||
"-H", "Accept-Encoding: gzip, deflate, identity",
|
||||
"-H", "DNT: 1",
|
||||
"-H", "Connection: keep-alive",
|
||||
"-H", "Upgrade-Insecure-Requests: 1",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -105,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())
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
32
frontend/package-lock.json
generated
32
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "vessel",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vessel",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.8",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.3",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
@@ -15,6 +15,8 @@
|
||||
"@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",
|
||||
@@ -1739,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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vessel",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.0",
|
||||
"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",
|
||||
@@ -37,10 +38,12 @@
|
||||
"@codemirror/lang-python": "^6.1.7",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@skeletonlabs/skeleton": "^2.10.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"@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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
270
frontend/src/lib/components/chat/AttachmentDisplay.svelte
Normal file
270
frontend/src/lib/components/chat/AttachmentDisplay.svelte
Normal 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}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -19,10 +23,10 @@
|
||||
formatResultsAsContext,
|
||||
getKnowledgeBaseStats
|
||||
} from '$lib/memory';
|
||||
import { runToolCalls, formatToolResultsForChat, getFunctionModel, USE_FUNCTION_MODEL } from '$lib/tools';
|
||||
import { runToolCalls, formatToolResultsForChat, getFunctionModel, USE_FUNCTION_MODEL, parseTextToolCalls } 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';
|
||||
@@ -32,26 +36,32 @@
|
||||
import SystemPromptSelector from './SystemPromptSelector.svelte';
|
||||
import ModelParametersPanel from '$lib/components/settings/ModelParametersPanel.svelte';
|
||||
import { settingsState } from '$lib/stores/settings.svelte';
|
||||
import { buildProjectContext, formatProjectContextForPrompt, hasProjectContext } from '$lib/services/project-context.js';
|
||||
import { updateSummaryOnLeave } from '$lib/services/conversation-summary.js';
|
||||
|
||||
/**
|
||||
* Props interface for ChatWindow
|
||||
* - mode: 'new' for new chat page, 'conversation' for existing conversations
|
||||
* - onFirstMessage: callback for when first message is sent in 'new' mode
|
||||
* - conversation: conversation metadata when in 'conversation' mode
|
||||
* - initialMessage: auto-send this message when conversation loads (for new project chats)
|
||||
*/
|
||||
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;
|
||||
/** Initial message to auto-send when conversation loads */
|
||||
initialMessage?: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
mode = 'new',
|
||||
onFirstMessage,
|
||||
conversation,
|
||||
thinkingEnabled = $bindable(true)
|
||||
thinkingEnabled = $bindable(true),
|
||||
initialMessage = null
|
||||
}: Props = $props();
|
||||
|
||||
// Local state for abort controller
|
||||
@@ -62,11 +72,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);
|
||||
@@ -118,6 +132,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Track if initial message has been sent to prevent re-sending
|
||||
let initialMessageSent = $state(false);
|
||||
|
||||
// Auto-send initial message when conversation is ready
|
||||
$effect(() => {
|
||||
if (
|
||||
mode === 'conversation' &&
|
||||
initialMessage &&
|
||||
!initialMessageSent &&
|
||||
chatState.conversationId === conversation?.id &&
|
||||
!chatState.isStreaming
|
||||
) {
|
||||
initialMessageSent = true;
|
||||
// Small delay to ensure UI is ready
|
||||
setTimeout(() => {
|
||||
handleSendMessage(initialMessage);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if knowledge base has any documents
|
||||
*/
|
||||
@@ -132,12 +166,22 @@
|
||||
|
||||
/**
|
||||
* Retrieve relevant context from knowledge base for the query
|
||||
* @param query - The search query
|
||||
* @param projectId - If set, search only project docs; if null, search global docs; if undefined, search all
|
||||
*/
|
||||
async function retrieveRagContext(query: string): Promise<string | null> {
|
||||
async function retrieveRagContext(
|
||||
query: string,
|
||||
projectId?: string | null
|
||||
): Promise<string | null> {
|
||||
if (!ragEnabled || !hasKnowledgeBase) return null;
|
||||
|
||||
try {
|
||||
const results = await searchSimilar(query, 3, 0.5);
|
||||
// Lower threshold (0.3) to catch more relevant results
|
||||
const results = await searchSimilar(query, {
|
||||
topK: 5,
|
||||
threshold: 0.3,
|
||||
projectId
|
||||
});
|
||||
if (results.length === 0) return null;
|
||||
|
||||
const context = formatResultsAsContext(results);
|
||||
@@ -148,6 +192,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve project context (instructions, summaries, chat history)
|
||||
* Only applicable when the conversation belongs to a project
|
||||
*/
|
||||
async function retrieveProjectContext(query: string): Promise<string | null> {
|
||||
const projectId = conversation?.projectId;
|
||||
const conversationId = chatState.conversationId;
|
||||
|
||||
if (!projectId || !conversationId) return null;
|
||||
|
||||
try {
|
||||
const context = await buildProjectContext(projectId, conversationId, query);
|
||||
if (!hasProjectContext(context)) return null;
|
||||
|
||||
return formatProjectContextForPrompt(context);
|
||||
} catch (error) {
|
||||
console.error('[ProjectContext] Failed to retrieve context:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OllamaToolCall to the format expected by tool executor
|
||||
* Ollama doesn't provide IDs, so we generate them
|
||||
@@ -213,16 +278,71 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Track previous conversation for summary generation on switch
|
||||
let previousConversationId: string | null = null;
|
||||
let previousConversationMessages: typeof chatState.visibleMessages = [];
|
||||
|
||||
// Trigger summary generation when leaving a conversation
|
||||
$effect(() => {
|
||||
const currentId = conversation?.id || null;
|
||||
const currentMessages = chatState.visibleMessages;
|
||||
const currentModel = modelsState.selectedId;
|
||||
|
||||
// Store current messages for when we leave
|
||||
if (currentId) {
|
||||
previousConversationMessages = [...currentMessages];
|
||||
}
|
||||
|
||||
// When conversation changes, summarize the previous one
|
||||
if (previousConversationId && previousConversationId !== currentId && currentModel) {
|
||||
// Need to copy values for the closure
|
||||
const prevId = previousConversationId;
|
||||
const prevMessages = previousConversationMessages.map((m) => ({
|
||||
role: m.message.role,
|
||||
content: m.message.content
|
||||
}));
|
||||
|
||||
updateSummaryOnLeave(prevId, prevMessages, currentModel);
|
||||
}
|
||||
|
||||
previousConversationId = currentId;
|
||||
});
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,6 +391,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
|
||||
// =========================================================================
|
||||
@@ -284,9 +447,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.');
|
||||
@@ -313,10 +476,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +491,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) {
|
||||
@@ -339,24 +502,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;
|
||||
}
|
||||
|
||||
@@ -379,36 +542,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();
|
||||
|
||||
@@ -416,48 +704,65 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Project context: Retrieve instructions, summaries, and chat history
|
||||
const lastUserMessage = messages.filter(m => m.role === 'user').pop();
|
||||
if (lastUserMessage && conversation?.projectId) {
|
||||
const projectContext = await retrieveProjectContext(lastUserMessage.content);
|
||||
if (projectContext) {
|
||||
systemParts.push(projectContext);
|
||||
}
|
||||
}
|
||||
|
||||
// RAG: Retrieve relevant context for the last user message
|
||||
const lastUserMessage = messages.filter(m => m.role === 'user').pop();
|
||||
// If in a project, search project documents; otherwise search global documents
|
||||
if (lastUserMessage && ragEnabled && hasKnowledgeBase) {
|
||||
const ragContext = await retrieveRagContext(lastUserMessage.content);
|
||||
const ragProjectId = conversation?.projectId ?? null;
|
||||
const ragContext = await retrieveRagContext(lastUserMessage.content, ragProjectId);
|
||||
if (ragContext) {
|
||||
lastRagContext = ragContext;
|
||||
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 = {
|
||||
@@ -489,6 +794,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
|
||||
@@ -500,6 +810,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');
|
||||
@@ -524,7 +839,7 @@
|
||||
streamingMetricsState.endStream();
|
||||
abortController = null;
|
||||
|
||||
// Handle tool calls if received
|
||||
// Handle native tool calls if received
|
||||
if (pendingToolCalls && pendingToolCalls.length > 0) {
|
||||
await executeToolsAndContinue(
|
||||
model,
|
||||
@@ -535,13 +850,41 @@
|
||||
return; // Tool continuation handles persistence
|
||||
}
|
||||
|
||||
// Check for text-based tool calls (models without native tool calling)
|
||||
const node = chatState.messageTree.get(assistantMessageId);
|
||||
if (node && toolsState.toolsEnabled) {
|
||||
const { toolCalls: textToolCalls, cleanContent } = parseTextToolCalls(node.message.content);
|
||||
if (textToolCalls.length > 0) {
|
||||
// Convert to OllamaToolCall format
|
||||
const convertedCalls: OllamaToolCall[] = textToolCalls.map(tc => ({
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments
|
||||
}
|
||||
}));
|
||||
|
||||
// Update message content to remove the raw tool call text
|
||||
if (cleanContent !== node.message.content) {
|
||||
node.message.content = cleanContent || 'Using tool...';
|
||||
}
|
||||
|
||||
await executeToolsAndContinue(
|
||||
model,
|
||||
assistantMessageId,
|
||||
convertedCalls,
|
||||
conversationId
|
||||
);
|
||||
return; // Tool continuation handles persistence
|
||||
}
|
||||
}
|
||||
|
||||
// Persist assistant message to IndexedDB with the SAME ID as chatState
|
||||
if (conversationId) {
|
||||
const node = chatState.messageTree.get(assistantMessageId);
|
||||
if (node) {
|
||||
const nodeForPersist = chatState.messageTree.get(assistantMessageId);
|
||||
if (nodeForPersist) {
|
||||
await addStoredMessage(
|
||||
conversationId,
|
||||
{ role: 'assistant', content: node.message.content },
|
||||
{ role: 'assistant', content: nodeForPersist.message.content },
|
||||
parentMessageId,
|
||||
assistantMessageId
|
||||
);
|
||||
@@ -549,9 +892,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;
|
||||
@@ -560,6 +909,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();
|
||||
@@ -708,7 +1061,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
|
||||
@@ -736,7 +1089,7 @@
|
||||
streamingMetricsState.endStream();
|
||||
abortController = null;
|
||||
|
||||
// Handle tool calls if received
|
||||
// Handle native tool calls if received
|
||||
if (pendingToolCalls && pendingToolCalls.length > 0) {
|
||||
await executeToolsAndContinue(
|
||||
selectedModel,
|
||||
@@ -747,13 +1100,41 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for text-based tool calls (models without native tool calling)
|
||||
const node = chatState.messageTree.get(newMessageId);
|
||||
if (node && toolsState.toolsEnabled) {
|
||||
const { toolCalls: textToolCalls, cleanContent } = parseTextToolCalls(node.message.content);
|
||||
if (textToolCalls.length > 0) {
|
||||
// Convert to OllamaToolCall format
|
||||
const convertedCalls: OllamaToolCall[] = textToolCalls.map(tc => ({
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments
|
||||
}
|
||||
}));
|
||||
|
||||
// Update message content to remove the raw tool call text
|
||||
if (cleanContent !== node.message.content) {
|
||||
node.message.content = cleanContent || 'Using tool...';
|
||||
}
|
||||
|
||||
await executeToolsAndContinue(
|
||||
selectedModel,
|
||||
newMessageId,
|
||||
convertedCalls,
|
||||
conversationId
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist regenerated assistant message to IndexedDB with the SAME ID
|
||||
if (conversationId && parentUserMessageId) {
|
||||
const node = chatState.messageTree.get(newMessageId);
|
||||
if (node) {
|
||||
const nodeForPersist = chatState.messageTree.get(newMessageId);
|
||||
if (nodeForPersist) {
|
||||
await addStoredMessage(
|
||||
conversationId,
|
||||
{ role: 'assistant', content: node.message.content },
|
||||
{ role: 'assistant', content: nodeForPersist.message.content },
|
||||
parentUserMessageId,
|
||||
newMessageId
|
||||
);
|
||||
@@ -765,6 +1146,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;
|
||||
@@ -773,6 +1156,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();
|
||||
@@ -826,7 +1212,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}
|
||||
@@ -886,10 +1272,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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
let { toolCalls }: Props = $props();
|
||||
|
||||
// Tool metadata for icons and colors
|
||||
const toolMeta: Record<string, { icon: string; color: string; label: string }> = {
|
||||
// Tool metadata for built-in tools (exact matches)
|
||||
const builtinToolMeta: Record<string, { icon: string; color: string; label: string }> = {
|
||||
get_location: {
|
||||
icon: '📍',
|
||||
color: 'from-rose-500 to-pink-600',
|
||||
@@ -41,12 +41,103 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Pattern-based styling for custom tools (checked in order, first match wins)
|
||||
const toolPatterns: Array<{ patterns: string[]; icon: string; color: string; label: string }> = [
|
||||
// Agentic Tools (check first for specific naming)
|
||||
{ patterns: ['task_manager', 'task-manager', 'taskmanager'], icon: '📋', color: 'from-indigo-500 to-purple-600', label: 'Tasks' },
|
||||
{ patterns: ['memory_store', 'memory-store', 'memorystore', 'scratchpad'], icon: '🧠', color: 'from-violet-500 to-purple-600', label: 'Memory' },
|
||||
{ patterns: ['think_step', 'structured_thinking', 'reasoning'], icon: '💭', color: 'from-cyan-500 to-blue-600', label: 'Thinking' },
|
||||
{ patterns: ['decision_matrix', 'decision-matrix', 'evaluate'], icon: '⚖️', color: 'from-amber-500 to-orange-600', label: 'Decision' },
|
||||
{ patterns: ['project_planner', 'project-planner', 'breakdown'], icon: '📊', color: 'from-emerald-500 to-teal-600', label: 'Planning' },
|
||||
// Design & UI
|
||||
{ patterns: ['design', 'brief', 'ui', 'ux', 'layout', 'wireframe'], icon: '🎨', color: 'from-pink-500 to-rose-600', label: 'Design' },
|
||||
{ patterns: ['color', 'palette', 'theme', 'style'], icon: '🎨', color: 'from-fuchsia-500 to-pink-600', label: 'Color' },
|
||||
// Search & Discovery
|
||||
{ patterns: ['search', 'find', 'lookup', 'query'], icon: '🔍', color: 'from-blue-500 to-cyan-600', label: 'Search' },
|
||||
// Web & API
|
||||
{ patterns: ['fetch', 'http', 'api', 'request', 'webhook'], icon: '🌐', color: 'from-violet-500 to-purple-600', label: 'API' },
|
||||
{ patterns: ['url', 'link', 'web', 'scrape'], icon: '🔗', color: 'from-indigo-500 to-violet-600', label: 'Web' },
|
||||
// Data & Analysis
|
||||
{ patterns: ['data', 'analyze', 'stats', 'chart', 'graph', 'metric'], icon: '📊', color: 'from-cyan-500 to-blue-600', label: 'Analysis' },
|
||||
{ patterns: ['json', 'transform', 'parse', 'convert', 'format'], icon: '🔄', color: 'from-sky-500 to-cyan-600', label: 'Transform' },
|
||||
// Math & Calculation
|
||||
{ patterns: ['calc', 'math', 'compute', 'formula', 'number'], icon: '🧮', color: 'from-emerald-500 to-teal-600', label: 'Calculate' },
|
||||
// Time & Date
|
||||
{ patterns: ['time', 'date', 'clock', 'schedule', 'calendar'], icon: '🕐', color: 'from-amber-500 to-orange-600', label: 'Time' },
|
||||
// Location & Maps
|
||||
{ patterns: ['location', 'geo', 'place', 'address', 'map', 'coord'], icon: '📍', color: 'from-rose-500 to-pink-600', label: 'Location' },
|
||||
// Text & String
|
||||
{ patterns: ['text', 'string', 'word', 'sentence', 'paragraph'], icon: '📝', color: 'from-slate-500 to-gray-600', label: 'Text' },
|
||||
// Files & Storage
|
||||
{ patterns: ['file', 'read', 'write', 'save', 'load', 'export', 'import'], icon: '📁', color: 'from-yellow-500 to-amber-600', label: 'File' },
|
||||
// Communication
|
||||
{ patterns: ['email', 'mail', 'send', 'message', 'notify', 'alert'], icon: '📧', color: 'from-red-500 to-rose-600', label: 'Message' },
|
||||
// User & Auth
|
||||
{ patterns: ['user', 'auth', 'login', 'account', 'profile', 'session'], icon: '👤', color: 'from-blue-500 to-indigo-600', label: 'User' },
|
||||
// Database
|
||||
{ patterns: ['database', 'db', 'sql', 'table', 'record', 'store'], icon: '🗄️', color: 'from-orange-500 to-red-600', label: 'Database' },
|
||||
// Code & Execution
|
||||
{ patterns: ['code', 'script', 'execute', 'run', 'shell', 'command'], icon: '💻', color: 'from-green-500 to-emerald-600', label: 'Code' },
|
||||
// Images & Media
|
||||
{ patterns: ['image', 'photo', 'picture', 'screenshot', 'media', 'video'], icon: '🖼️', color: 'from-purple-500 to-fuchsia-600', label: 'Media' },
|
||||
// Weather
|
||||
{ patterns: ['weather', 'forecast', 'temperature', 'climate'], icon: '🌤️', color: 'from-sky-400 to-blue-500', label: 'Weather' },
|
||||
// Translation & Language
|
||||
{ patterns: ['translate', 'language', 'i18n', 'locale'], icon: '🌍', color: 'from-teal-500 to-cyan-600', label: 'Translate' },
|
||||
// Security & Encryption
|
||||
{ patterns: ['encrypt', 'decrypt', 'hash', 'encode', 'decode', 'secure', 'password'], icon: '🔐', color: 'from-red-600 to-orange-600', label: 'Security' },
|
||||
// Random & Generation
|
||||
{ patterns: ['random', 'generate', 'uuid', 'create', 'make'], icon: '🎲', color: 'from-violet-500 to-purple-600', label: 'Generate' },
|
||||
// Lists & Collections
|
||||
{ patterns: ['list', 'array', 'collection', 'filter', 'sort'], icon: '📋', color: 'from-blue-400 to-indigo-500', label: 'List' },
|
||||
// Validation & Check
|
||||
{ patterns: ['valid', 'check', 'verify', 'test', 'assert'], icon: '✅', color: 'from-green-500 to-teal-600', label: 'Validate' }
|
||||
];
|
||||
|
||||
const defaultMeta = {
|
||||
icon: '⚙️',
|
||||
color: 'from-gray-500 to-gray-600',
|
||||
color: 'from-slate-500 to-slate-600',
|
||||
label: 'Tool'
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tool metadata - checks builtin tools first, then pattern matches, then default
|
||||
*/
|
||||
function getToolMeta(toolName: string): { icon: string; color: string; label: string } {
|
||||
// Check builtin tools first (exact match)
|
||||
if (builtinToolMeta[toolName]) {
|
||||
return builtinToolMeta[toolName];
|
||||
}
|
||||
|
||||
// Pattern match for custom tools
|
||||
const lowerName = toolName.toLowerCase();
|
||||
for (const pattern of toolPatterns) {
|
||||
if (pattern.patterns.some((p) => lowerName.includes(p))) {
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return defaultMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tool name to human-readable label
|
||||
*/
|
||||
function formatToolLabel(toolName: string, detectedLabel: string): string {
|
||||
// If it's a known builtin or detected pattern, use that label
|
||||
if (detectedLabel !== 'Tool') {
|
||||
return detectedLabel;
|
||||
}
|
||||
// Otherwise, humanize the tool name
|
||||
return toolName
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(' ')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse arguments to display-friendly format
|
||||
*/
|
||||
@@ -200,7 +291,8 @@
|
||||
|
||||
<div class="my-3 space-y-2">
|
||||
{#each toolCalls as call (call.id)}
|
||||
{@const meta = toolMeta[call.name] || defaultMeta}
|
||||
{@const meta = getToolMeta(call.name)}
|
||||
{@const displayLabel = formatToolLabel(call.name, meta.label)}
|
||||
{@const args = parseArgs(call.arguments)}
|
||||
{@const argEntries = Object.entries(args).filter(([_, v]) => v !== undefined && v !== null)}
|
||||
{@const isExpanded = expandedCalls.has(call.id)}
|
||||
@@ -216,12 +308,12 @@
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-100/50 dark:hover:bg-slate-700/50"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<span class="text-xl" role="img" aria-label={meta.label}>{meta.icon}</span>
|
||||
<span class="text-xl" role="img" aria-label={displayLabel}>{meta.icon}</span>
|
||||
|
||||
<!-- Tool name and summary -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-slate-800 dark:text-slate-100">{meta.label}</span>
|
||||
<span class="font-medium text-slate-800 dark:text-slate-100">{displayLabel}</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">{call.name}</span>
|
||||
</div>
|
||||
|
||||
|
||||
314
frontend/src/lib/components/chat/VirtualMessageList.svelte
Normal file
314
frontend/src/lib/components/chat/VirtualMessageList.svelte
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ConversationItem.svelte - Single conversation row in the sidebar
|
||||
* Shows title, model, and hover actions (pin, export, delete)
|
||||
* Shows title, model, and hover actions (pin, move, export, delete)
|
||||
*/
|
||||
import type { Conversation } from '$lib/types/conversation.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { conversationsState, uiState, chatState, toastState } from '$lib/stores';
|
||||
import { deleteConversation } from '$lib/storage';
|
||||
import { ExportDialog } from '$lib/components/shared';
|
||||
import MoveToProjectModal from '$lib/components/projects/MoveToProjectModal.svelte';
|
||||
|
||||
interface Props {
|
||||
conversation: Conversation;
|
||||
@@ -19,6 +20,9 @@
|
||||
// Export dialog state
|
||||
let showExportDialog = $state(false);
|
||||
|
||||
// Move to project dialog state
|
||||
let showMoveDialog = $state(false);
|
||||
|
||||
/** Format relative time for display */
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
@@ -48,6 +52,13 @@
|
||||
showExportDialog = true;
|
||||
}
|
||||
|
||||
/** Handle move to project */
|
||||
function handleMove(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showMoveDialog = true;
|
||||
}
|
||||
|
||||
/** Handle delete */
|
||||
async function handleDelete(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
@@ -174,6 +185,30 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Move to project button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMove}
|
||||
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||
aria-label="Move to project"
|
||||
title="Move to project"
|
||||
>
|
||||
<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="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Export button -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -230,3 +265,10 @@
|
||||
isOpen={showExportDialog}
|
||||
onClose={() => (showExportDialog = false)}
|
||||
/>
|
||||
|
||||
<!-- Move to Project Modal -->
|
||||
<MoveToProjectModal
|
||||
conversationId={conversation.id}
|
||||
isOpen={showMoveDialog}
|
||||
onClose={() => (showMoveDialog = false)}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ConversationList.svelte - Chat history list grouped by date
|
||||
* Uses local conversationsState for immediate updates (offline-first)
|
||||
* ConversationList.svelte - Chat history list with projects and date groups
|
||||
* Shows projects as folders at the top, then ungrouped conversations by date
|
||||
*/
|
||||
import { conversationsState, chatState } from '$lib/stores';
|
||||
import { conversationsState, chatState, projectsState } from '$lib/stores';
|
||||
import ConversationItem from './ConversationItem.svelte';
|
||||
import ProjectFolder from './ProjectFolder.svelte';
|
||||
import type { Conversation } from '$lib/types/conversation.js';
|
||||
|
||||
interface Props {
|
||||
onEditProject?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
let { onEditProject }: Props = $props();
|
||||
|
||||
// State for showing archived conversations
|
||||
let showArchived = $state(false);
|
||||
|
||||
// Derived: Conversations without a project, grouped by date
|
||||
const ungroupedConversations = $derived.by(() => {
|
||||
return conversationsState.withoutProject();
|
||||
});
|
||||
|
||||
// Derived: Check if there are any project folders or ungrouped conversations
|
||||
const hasAnyContent = $derived.by(() => {
|
||||
return projectsState.projects.length > 0 || ungroupedConversations.length > 0;
|
||||
});
|
||||
|
||||
// Derived: Map of project ID to conversations (cached to avoid repeated calls)
|
||||
const projectConversationsMap = $derived.by(() => {
|
||||
const map = new Map<string, Conversation[]>();
|
||||
for (const project of projectsState.projects) {
|
||||
map.set(project.id, conversationsState.forProject(project.id));
|
||||
}
|
||||
return map;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col px-2 py-1">
|
||||
{#if conversationsState.grouped.length === 0}
|
||||
{#if !hasAnyContent && conversationsState.grouped.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-col items-center justify-center px-4 py-8 text-center">
|
||||
<svg
|
||||
@@ -43,24 +70,45 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Grouped conversations -->
|
||||
{#each conversationsState.grouped as { group, conversations } (group)}
|
||||
<div class="mb-2">
|
||||
<!-- Group header -->
|
||||
<!-- Projects section -->
|
||||
{#if projectsState.sortedProjects.length > 0}
|
||||
<div class="mb-3">
|
||||
<h3 class="sticky top-0 z-10 bg-theme-primary px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-theme-muted">
|
||||
{group}
|
||||
Projects
|
||||
</h3>
|
||||
|
||||
<!-- Conversations in this group -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each conversations as conversation (conversation.id)}
|
||||
<ConversationItem
|
||||
{conversation}
|
||||
isSelected={chatState.conversationId === conversation.id}
|
||||
{#each projectsState.sortedProjects as project (project.id)}
|
||||
<ProjectFolder
|
||||
{project}
|
||||
conversations={projectConversationsMap.get(project.id) ?? []}
|
||||
{onEditProject}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ungrouped conversations (by date) -->
|
||||
{#each conversationsState.grouped as { group, conversations } (group)}
|
||||
{@const ungroupedInGroup = conversations.filter(c => !c.projectId)}
|
||||
{#if ungroupedInGroup.length > 0}
|
||||
<div class="mb-2">
|
||||
<!-- Group header -->
|
||||
<h3 class="sticky top-0 z-10 bg-theme-primary px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-theme-muted">
|
||||
{group}
|
||||
</h3>
|
||||
|
||||
<!-- Conversations in this group (without project) -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each ungroupedInGroup as conversation (conversation.id)}
|
||||
<ConversationItem
|
||||
{conversation}
|
||||
isSelected={chatState.conversationId === conversation.id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Archived section -->
|
||||
|
||||
143
frontend/src/lib/components/layout/ProjectFolder.svelte
Normal file
143
frontend/src/lib/components/layout/ProjectFolder.svelte
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ProjectFolder.svelte - Collapsible folder for project conversations
|
||||
* Shows project name, color indicator, and nested conversations
|
||||
*/
|
||||
import type { Project } from '$lib/stores/projects.svelte.js';
|
||||
import type { Conversation } from '$lib/types/conversation.js';
|
||||
import { projectsState, chatState } from '$lib/stores';
|
||||
import ConversationItem from './ConversationItem.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
conversations: Conversation[];
|
||||
onEditProject?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
let { project, conversations, onEditProject }: Props = $props();
|
||||
|
||||
// Track if this project is expanded
|
||||
const isExpanded = $derived(!projectsState.collapsedIds.has(project.id));
|
||||
|
||||
/** Toggle folder collapse state */
|
||||
async function handleToggle(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await projectsState.toggleCollapse(project.id);
|
||||
}
|
||||
|
||||
/** Navigate to project page */
|
||||
function handleOpenProject(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
goto(`/projects/${project.id}`);
|
||||
}
|
||||
|
||||
/** Handle project settings click */
|
||||
function handleSettings(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEditProject?.(project.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-1">
|
||||
<!-- Project header -->
|
||||
<div class="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-theme-secondary/60">
|
||||
<!-- Collapse indicator (clickable) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleToggle}
|
||||
class="shrink-0 rounded p-0.5 text-theme-muted transition-colors hover:text-theme-primary"
|
||||
aria-label={isExpanded ? 'Collapse project' : 'Expand project'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3 transition-transform {isExpanded ? 'rotate-90' : ''}"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Project link (folder icon + name) - navigates to project page -->
|
||||
<a
|
||||
href="/projects/{project.id}"
|
||||
onclick={handleOpenProject}
|
||||
class="flex flex-1 items-center gap-2 truncate"
|
||||
title="Open project"
|
||||
>
|
||||
<!-- Folder icon with project color -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill={project.color || '#10b981'}
|
||||
>
|
||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
|
||||
<!-- Project name -->
|
||||
<span class="flex-1 truncate text-sm font-medium text-theme-secondary hover:text-theme-primary">
|
||||
{project.name}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Conversation count -->
|
||||
<span class="shrink-0 text-xs text-theme-muted">
|
||||
{conversations.length}
|
||||
</span>
|
||||
|
||||
<!-- Settings button (hidden until hover) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSettings}
|
||||
class="shrink-0 rounded p-0.5 text-theme-muted opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-theme-primary group-hover:opacity-100"
|
||||
aria-label="Project settings"
|
||||
title="Settings"
|
||||
>
|
||||
<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="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conversations in this project -->
|
||||
{#if isExpanded && conversations.length > 0}
|
||||
<div class="ml-3 flex flex-col gap-0.5 border-l border-theme/30 pl-2">
|
||||
{#each conversations as conversation (conversation.id)}
|
||||
<ConversationItem
|
||||
{conversation}
|
||||
isSelected={chatState.conversationId === conversation.id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state for expanded folder with no conversations -->
|
||||
{#if isExpanded && conversations.length === 0}
|
||||
<div class="ml-3 border-l border-theme/30 pl-2">
|
||||
<p class="px-3 py-2 text-xs text-theme-muted italic">
|
||||
No conversations yet
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,20 +1,33 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Sidenav.svelte - Collapsible sidebar for the Ollama chat UI
|
||||
* Contains navigation header, search, and conversation list
|
||||
* Contains navigation header, search, projects, and conversation list
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { uiState } from '$lib/stores';
|
||||
import SidenavHeader from './SidenavHeader.svelte';
|
||||
import SidenavSearch from './SidenavSearch.svelte';
|
||||
import ConversationList from './ConversationList.svelte';
|
||||
import { SettingsModal } from '$lib/components/shared';
|
||||
import ProjectModal from '$lib/components/projects/ProjectModal.svelte';
|
||||
|
||||
// Check if a path is active
|
||||
const isActive = (path: string) => $page.url.pathname === path;
|
||||
// Project modal state
|
||||
let showProjectModal = $state(false);
|
||||
let editingProjectId = $state<string | null>(null);
|
||||
|
||||
// Settings modal state
|
||||
let settingsOpen = $state(false);
|
||||
function handleCreateProject() {
|
||||
editingProjectId = null;
|
||||
showProjectModal = true;
|
||||
}
|
||||
|
||||
function handleEditProject(projectId: string) {
|
||||
editingProjectId = projectId;
|
||||
showProjectModal = true;
|
||||
}
|
||||
|
||||
function handleCloseProjectModal() {
|
||||
showProjectModal = false;
|
||||
editingProjectId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Overlay for mobile (closes sidenav when clicking outside) -->
|
||||
@@ -42,106 +55,41 @@
|
||||
<!-- Search bar -->
|
||||
<SidenavSearch />
|
||||
|
||||
<!-- Conversation list (scrollable) -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<ConversationList />
|
||||
</div>
|
||||
|
||||
<!-- Footer / Navigation links -->
|
||||
<div class="border-t border-theme p-3 space-y-1">
|
||||
<!-- Model Browser link -->
|
||||
<a
|
||||
href="/models"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/models') ? 'bg-cyan-500/20 text-cyan-600 dark:bg-cyan-900/30 dark:text-cyan-400' : 'text-theme-muted hover:bg-theme-hover 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="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 0-6.23.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
|
||||
/>
|
||||
</svg>
|
||||
<span>Models</span>
|
||||
</a>
|
||||
|
||||
<!-- Knowledge Base link -->
|
||||
<a
|
||||
href="/knowledge"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/knowledge') ? 'bg-blue-500/20 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'text-theme-muted hover:bg-theme-hover 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="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
<span>Knowledge Base</span>
|
||||
</a>
|
||||
|
||||
<!-- Tools link -->
|
||||
<a
|
||||
href="/tools"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/tools') ? 'bg-emerald-500/20 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400' : 'text-theme-muted hover:bg-theme-hover 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="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Tools</span>
|
||||
</a>
|
||||
|
||||
<!-- Prompts link -->
|
||||
<a
|
||||
href="/prompts"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/prompts') ? 'bg-purple-500/20 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400' : 'text-theme-muted hover:bg-theme-hover 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="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-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 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Prompts</span>
|
||||
</a>
|
||||
|
||||
<!-- Settings button -->
|
||||
<!-- Create Project button -->
|
||||
<div class="px-3 pb-2">
|
||||
<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"
|
||||
onclick={handleCreateProject}
|
||||
class="flex w-full items-center gap-2 rounded-lg border border-dashed border-theme px-3 py-2 text-sm text-theme-muted transition-colors hover:border-emerald-500/50 hover:bg-theme-secondary/50 hover:text-emerald-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 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
<span>New Project</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conversation list (scrollable) -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<ConversationList onEditProject={handleEditProject} />
|
||||
</div>
|
||||
|
||||
<!-- Footer / Settings link -->
|
||||
<div class="border-t border-theme p-3">
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {$page.url.pathname.startsWith('/settings') ? 'bg-violet-500/20 text-violet-600 dark:bg-violet-900/30 dark:text-violet-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -159,10 +107,14 @@
|
||||
<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)} />
|
||||
<!-- Project Modal -->
|
||||
<ProjectModal
|
||||
isOpen={showProjectModal}
|
||||
onClose={handleCloseProjectModal}
|
||||
projectId={editingProjectId}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SidenavSearch.svelte - Search input for filtering conversations
|
||||
* Uses local conversationsState for instant client-side filtering
|
||||
* SidenavSearch.svelte - Search input that navigates to search page
|
||||
*/
|
||||
import { goto } from '$app/navigation';
|
||||
import { conversationsState } from '$lib/stores';
|
||||
|
||||
// Handle input change - directly updates store for instant filtering
|
||||
let searchValue = $state('');
|
||||
|
||||
// Handle input change - only filter locally, navigate on Enter
|
||||
function handleInput(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
conversationsState.searchQuery = value;
|
||||
searchValue = value;
|
||||
conversationsState.searchQuery = value; // Local filtering in sidebar
|
||||
}
|
||||
|
||||
// Handle clear button
|
||||
function handleClear() {
|
||||
searchValue = '';
|
||||
conversationsState.clearSearch();
|
||||
}
|
||||
|
||||
// Handle Enter key to navigate to search page
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && searchValue.trim()) {
|
||||
goto(`/search?query=${encodeURIComponent(searchValue)}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="px-3 pb-2">
|
||||
@@ -38,15 +49,16 @@
|
||||
<!-- Search input -->
|
||||
<input
|
||||
type="text"
|
||||
value={conversationsState.searchQuery}
|
||||
bind:value={searchValue}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Search conversations..."
|
||||
data-search-input
|
||||
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary/50 py-2 pl-10 pr-9 text-sm text-theme-primary placeholder-theme-placeholder transition-colors focus:border-violet-500/50 focus:bg-theme-tertiary focus:outline-none focus:ring-1 focus:ring-violet-500/50"
|
||||
class="w-full rounded-lg border border-theme bg-slate-800 py-2 pl-10 pr-9 text-sm text-white placeholder-slate-400 transition-colors focus:border-violet-500/50 focus:bg-slate-700 focus:outline-none focus:ring-1 focus:ring-violet-500/50"
|
||||
/>
|
||||
|
||||
<!-- Clear button (visible when there's text) -->
|
||||
{#if conversationsState.searchQuery}
|
||||
{#if searchValue}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClear}
|
||||
|
||||
309
frontend/src/lib/components/models/ModelEditorDialog.svelte
Normal file
309
frontend/src/lib/components/models/ModelEditorDialog.svelte
Normal 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}
|
||||
176
frontend/src/lib/components/projects/MoveToProjectModal.svelte
Normal file
176
frontend/src/lib/components/projects/MoveToProjectModal.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* MoveToProjectModal - Move a conversation to a different project
|
||||
*/
|
||||
import { projectsState, conversationsState, toastState } from '$lib/stores';
|
||||
import { moveConversationToProject } from '$lib/storage/conversations.js';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, conversationId }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Get current conversation's project
|
||||
const currentConversation = $derived.by(() => {
|
||||
return conversationsState.find(conversationId);
|
||||
});
|
||||
|
||||
const currentProjectId = $derived(currentConversation?.projectId || null);
|
||||
|
||||
async function handleSelect(projectId: string | null) {
|
||||
if (projectId === currentProjectId) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const result = await moveConversationToProject(conversationId, projectId);
|
||||
if (result.success) {
|
||||
// Update local state
|
||||
conversationsState.moveToProject(conversationId, projectId);
|
||||
|
||||
const projectName = projectId
|
||||
? projectsState.projects.find(p => p.id === projectId)?.name || 'project'
|
||||
: 'No Project';
|
||||
toastState.success(`Moved to ${projectName}`);
|
||||
onClose();
|
||||
} else {
|
||||
toastState.error('Failed to move conversation');
|
||||
}
|
||||
} catch {
|
||||
toastState.error('Failed to move conversation');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="move-dialog-title"
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<div class="mx-4 w-full max-w-sm rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||
<h2 id="move-dialog-title" class="text-lg font-semibold text-theme-primary">
|
||||
Move to Project
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-1.5 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<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 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-[50vh] overflow-y-auto px-2 py-3">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- No Project option -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(null)}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-theme-secondary {currentProjectId === null ? 'bg-theme-secondary' : ''}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-theme-muted"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm text-theme-secondary">No Project</span>
|
||||
{#if currentProjectId === null}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ml-auto h-5 w-5 text-emerald-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Project options -->
|
||||
{#if projectsState.sortedProjects.length > 0}
|
||||
<div class="my-2 border-t border-theme"></div>
|
||||
{#each projectsState.sortedProjects as project (project.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(project.id)}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-theme-secondary {currentProjectId === project.id ? 'bg-theme-secondary' : ''}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill={project.color || '#10b981'}
|
||||
>
|
||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
<span class="truncate text-sm text-theme-secondary">{project.name}</span>
|
||||
{#if currentProjectId === project.id}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ml-auto h-5 w-5 shrink-0 text-emerald-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if projectsState.sortedProjects.length === 0}
|
||||
<p class="px-4 py-6 text-center text-sm text-theme-muted">
|
||||
No projects yet. Create one from the sidebar.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
460
frontend/src/lib/components/projects/ProjectModal.svelte
Normal file
460
frontend/src/lib/components/projects/ProjectModal.svelte
Normal file
@@ -0,0 +1,460 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ProjectModal - Create/Edit project with tabs for settings, instructions, and links
|
||||
*/
|
||||
import { projectsState, toastState } from '$lib/stores';
|
||||
import type { Project } from '$lib/stores/projects.svelte.js';
|
||||
import { addProjectLink, deleteProjectLink, getProjectLinks, type ProjectLink } from '$lib/storage/projects.js';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId?: string | null;
|
||||
onUpdate?: () => void; // Called when project data changes (links added/deleted, etc.)
|
||||
}
|
||||
|
||||
let { isOpen, onClose, projectId = null, onUpdate }: Props = $props();
|
||||
|
||||
// Form state
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let instructions = $state('');
|
||||
let color = $state('#10b981');
|
||||
let links = $state<ProjectLink[]>([]);
|
||||
let newLinkUrl = $state('');
|
||||
let newLinkTitle = $state('');
|
||||
let newLinkDescription = $state('');
|
||||
let isLoading = $state(false);
|
||||
let activeTab = $state<'settings' | 'instructions' | 'links'>('settings');
|
||||
|
||||
// Predefined colors for quick selection
|
||||
const presetColors = [
|
||||
'#10b981', // emerald
|
||||
'#3b82f6', // blue
|
||||
'#8b5cf6', // violet
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#84cc16', // lime
|
||||
];
|
||||
|
||||
// Get existing project data when editing
|
||||
const existingProject = $derived.by(() => {
|
||||
if (!projectId) return null;
|
||||
return projectsState.projects.find(p => p.id === projectId) || null;
|
||||
});
|
||||
|
||||
// Modal title
|
||||
const modalTitle = $derived(projectId ? 'Edit Project' : 'Create Project');
|
||||
|
||||
// Reset form when modal opens/closes or project changes
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
if (existingProject) {
|
||||
name = existingProject.name;
|
||||
description = existingProject.description || '';
|
||||
instructions = existingProject.instructions || '';
|
||||
color = existingProject.color || '#10b981';
|
||||
loadProjectLinks();
|
||||
} else {
|
||||
name = '';
|
||||
description = '';
|
||||
instructions = '';
|
||||
color = '#10b981';
|
||||
links = [];
|
||||
}
|
||||
activeTab = 'settings';
|
||||
}
|
||||
});
|
||||
|
||||
async function loadProjectLinks() {
|
||||
if (!projectId) return;
|
||||
const result = await getProjectLinks(projectId);
|
||||
if (result.success) {
|
||||
links = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
toastState.error('Project name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
if (projectId) {
|
||||
// Update existing project
|
||||
const success = await projectsState.update(projectId, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
instructions: instructions.trim(),
|
||||
color
|
||||
});
|
||||
|
||||
if (success) {
|
||||
toastState.success('Project updated');
|
||||
onClose();
|
||||
} else {
|
||||
toastState.error('Failed to update project');
|
||||
}
|
||||
} else {
|
||||
// Create new project
|
||||
const project = await projectsState.add({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
instructions: instructions.trim(),
|
||||
color
|
||||
});
|
||||
|
||||
if (project) {
|
||||
toastState.success('Project created');
|
||||
onClose();
|
||||
} else {
|
||||
toastState.error('Failed to create project');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!projectId) return;
|
||||
|
||||
if (!confirm('Delete this project? Conversations will be unlinked but not deleted.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const success = await projectsState.remove(projectId);
|
||||
if (success) {
|
||||
toastState.success('Project deleted');
|
||||
onClose();
|
||||
} else {
|
||||
toastState.error('Failed to delete project');
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddLink() {
|
||||
if (!projectId || !newLinkUrl.trim()) {
|
||||
toastState.error('URL is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await addProjectLink({
|
||||
projectId,
|
||||
url: newLinkUrl.trim(),
|
||||
title: newLinkTitle.trim() || newLinkUrl.trim(),
|
||||
description: newLinkDescription.trim()
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
links = [...links, result.data];
|
||||
newLinkUrl = '';
|
||||
newLinkTitle = '';
|
||||
newLinkDescription = '';
|
||||
toastState.success('Link added');
|
||||
onUpdate?.(); // Notify parent to refresh
|
||||
} else {
|
||||
toastState.error('Failed to add link');
|
||||
}
|
||||
} catch {
|
||||
toastState.error('Failed to add link');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteLink(linkId: string) {
|
||||
try {
|
||||
const result = await deleteProjectLink(linkId);
|
||||
if (result.success) {
|
||||
links = links.filter(l => l.id !== linkId);
|
||||
toastState.success('Link removed');
|
||||
onUpdate?.(); // Notify parent to refresh
|
||||
} else {
|
||||
toastState.error('Failed to remove link');
|
||||
}
|
||||
} catch {
|
||||
toastState.error('Failed to remove link');
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="project-dialog-title"
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<div class="mx-4 w-full max-w-lg rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||
<h2 id="project-dialog-title" class="text-lg font-semibold text-theme-primary">
|
||||
{modalTitle}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-1.5 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<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 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-theme px-6">
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'settings')}
|
||||
class="relative py-3 text-sm font-medium transition-colors {activeTab === 'settings' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
Settings
|
||||
{#if activeTab === 'settings'}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'instructions')}
|
||||
class="relative py-3 text-sm font-medium transition-colors {activeTab === 'instructions' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
Instructions
|
||||
{#if activeTab === 'instructions'}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if projectId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'links')}
|
||||
class="relative py-3 text-sm font-medium transition-colors {activeTab === 'links' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
Links ({links.length})
|
||||
{#if activeTab === 'links'}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-[50vh] overflow-y-auto px-6 py-4">
|
||||
{#if activeTab === 'settings'}
|
||||
<!-- Settings Tab -->
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="project-name" class="mb-1.5 block text-sm font-medium text-theme-secondary">
|
||||
Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="My Project"
|
||||
class="w-full rounded-lg border border-theme bg-theme-tertiary px-3 py-2 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="project-description" class="mb-1.5 block text-sm font-medium text-theme-secondary">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
id="project-description"
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder="Optional description"
|
||||
class="w-full rounded-lg border border-theme bg-theme-tertiary px-3 py-2 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-theme-secondary">
|
||||
Color
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
{#each presetColors as presetColor}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (color = presetColor)}
|
||||
class="h-6 w-6 rounded-full border-2 transition-transform hover:scale-110 {color === presetColor ? 'border-white shadow-lg' : 'border-transparent'}"
|
||||
style="background-color: {presetColor}"
|
||||
aria-label="Select color {presetColor}"
|
||||
></button>
|
||||
{/each}
|
||||
<input
|
||||
type="color"
|
||||
bind:value={color}
|
||||
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent"
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'instructions'}
|
||||
<!-- Instructions Tab -->
|
||||
<div>
|
||||
<label for="project-instructions" class="mb-1.5 block text-sm font-medium text-theme-secondary">
|
||||
Project Instructions
|
||||
</label>
|
||||
<p class="mb-2 text-xs text-theme-muted">
|
||||
These instructions are injected into the system prompt for all chats in this project.
|
||||
</p>
|
||||
<textarea
|
||||
id="project-instructions"
|
||||
bind:value={instructions}
|
||||
rows="10"
|
||||
placeholder="You are helping with..."
|
||||
class="w-full rounded-lg border border-theme bg-theme-tertiary px-3 py-2 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
|
||||
></textarea>
|
||||
</div>
|
||||
{:else if activeTab === 'links'}
|
||||
<!-- Links Tab -->
|
||||
<div class="space-y-4">
|
||||
<!-- Add new link form -->
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary/30 p-3">
|
||||
<h4 class="mb-2 text-sm font-medium text-theme-secondary">Add Reference Link</h4>
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={newLinkUrl}
|
||||
placeholder="https://..."
|
||||
class="w-full rounded border border-theme bg-theme-tertiary px-2 py-1.5 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newLinkTitle}
|
||||
placeholder="Title (optional)"
|
||||
class="w-full rounded border border-theme bg-theme-tertiary px-2 py-1.5 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newLinkDescription}
|
||||
placeholder="Description (optional)"
|
||||
class="w-full rounded border border-theme bg-theme-tertiary px-2 py-1.5 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleAddLink}
|
||||
disabled={!newLinkUrl.trim()}
|
||||
class="w-full rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
Add Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing links -->
|
||||
{#if links.length === 0}
|
||||
<p class="py-4 text-center text-sm text-theme-muted">No links added yet</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each links as link (link.id)}
|
||||
<div class="flex items-start gap-2 rounded-lg border border-theme bg-theme-secondary/30 p-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block truncate text-sm font-medium text-emerald-500 hover:text-emerald-400"
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
{#if link.description}
|
||||
<p class="truncate text-xs text-theme-muted">{link.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDeleteLink(link.id)}
|
||||
class="shrink-0 rounded p-1 text-theme-muted hover:bg-red-900/50 hover:text-red-400"
|
||||
aria-label="Remove link"
|
||||
>
|
||||
<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="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between border-t border-theme px-6 py-4">
|
||||
<div>
|
||||
{#if projectId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
disabled={isLoading}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-red-500 transition-colors hover:bg-red-900/30 disabled:opacity-50"
|
||||
>
|
||||
Delete Project
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSave}
|
||||
disabled={isLoading || !name.trim()}
|
||||
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Saving...' : projectId ? 'Save Changes' : 'Create Project'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
155
frontend/src/lib/components/settings/GeneralTab.svelte
Normal file
155
frontend/src/lib/components/settings/GeneralTab.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* GeneralTab - General settings including appearance, defaults, shortcuts, and about
|
||||
*/
|
||||
import { modelsState, uiState } from '$lib/stores';
|
||||
import { getPrimaryModifierDisplay } from '$lib/utils';
|
||||
|
||||
const modifierKey = getPrimaryModifierDisplay();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Appearance 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-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>
|
||||
<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>
|
||||
|
||||
<!-- Keyboard Shortcuts 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-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>
|
||||
275
frontend/src/lib/components/settings/KnowledgeTab.svelte
Normal file
275
frontend/src/lib/components/settings/KnowledgeTab.svelte
Normal file
@@ -0,0 +1,275 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* KnowledgeTab - Knowledge Base management
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
listDocuments,
|
||||
addDocument,
|
||||
deleteDocument,
|
||||
getKnowledgeBaseStats,
|
||||
formatTokenCount,
|
||||
EMBEDDING_MODELS,
|
||||
DEFAULT_EMBEDDING_MODEL
|
||||
} from '$lib/memory';
|
||||
import type { StoredDocument } from '$lib/storage/db';
|
||||
import { toastState, modelsState } from '$lib/stores';
|
||||
|
||||
let documents = $state<StoredDocument[]>([]);
|
||||
let stats = $state({ documentCount: 0, chunkCount: 0, totalTokens: 0 });
|
||||
let isLoading = $state(true);
|
||||
let isUploading = $state(false);
|
||||
let uploadProgress = $state({ current: 0, total: 0 });
|
||||
let selectedModel = $state(DEFAULT_EMBEDDING_MODEL);
|
||||
let dragOver = $state(false);
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
onMount(async () => {
|
||||
await refreshData();
|
||||
});
|
||||
|
||||
async function refreshData() {
|
||||
isLoading = true;
|
||||
try {
|
||||
documents = await listDocuments();
|
||||
stats = await getKnowledgeBaseStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents:', error);
|
||||
toastState.error('Failed to load knowledge base');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
await processFiles(Array.from(input.files));
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragOver = false;
|
||||
|
||||
if (event.dataTransfer?.files) {
|
||||
await processFiles(Array.from(event.dataTransfer.files));
|
||||
}
|
||||
}
|
||||
|
||||
async function processFiles(files: File[]) {
|
||||
isUploading = true;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await file.text();
|
||||
|
||||
if (!content.trim()) {
|
||||
toastState.warning(`File "${file.name}" is empty, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await addDocument(file.name, content, file.type || 'text/plain', {
|
||||
embeddingModel: selectedModel,
|
||||
onProgress: (current, total) => {
|
||||
uploadProgress = { current, total };
|
||||
}
|
||||
});
|
||||
|
||||
toastState.success(`Added "${file.name}" to knowledge base`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to process ${file.name}:`, error);
|
||||
toastState.error(`Failed to add "${file.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
await refreshData();
|
||||
isUploading = false;
|
||||
uploadProgress = { current: 0, total: 0 };
|
||||
}
|
||||
|
||||
async function handleDelete(doc: StoredDocument) {
|
||||
if (!confirm(`Delete "${doc.name}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteDocument(doc.id);
|
||||
toastState.success(`Deleted "${doc.name}"`);
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete document:', error);
|
||||
toastState.error('Failed to delete document');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-theme-primary">Knowledge Base</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Upload documents to enhance AI responses with your own knowledge
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mb-6 grid grid-cols-3 gap-4">
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Documents</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-theme-primary">{stats.documentCount}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Chunks</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-theme-primary">{stats.chunkCount}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Total Tokens</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-theme-primary">{formatTokenCount(stats.totalTokens)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Area -->
|
||||
<div class="mb-8">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-theme-primary">Upload Documents</h3>
|
||||
<select
|
||||
bind:value={selectedModel}
|
||||
class="rounded-md border border-theme-subtle bg-theme-tertiary px-3 py-1.5 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{#each EMBEDDING_MODELS as model}
|
||||
<option value={model}>{model}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border-2 border-dashed p-8 text-center transition-colors {dragOver
|
||||
? 'border-blue-500 bg-blue-900/20'
|
||||
: 'border-theme-subtle hover:border-theme'}"
|
||||
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => fileInput?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{#if isUploading}
|
||||
<div class="flex flex-col items-center">
|
||||
<svg class="h-8 w-8 animate-spin text-blue-500" 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>
|
||||
<p class="mt-3 text-sm text-theme-muted">Processing... ({uploadProgress.current}/{uploadProgress.total} chunks)</p>
|
||||
</div>
|
||||
{:else}
|
||||
<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="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z" />
|
||||
</svg>
|
||||
<p class="mt-3 text-sm text-theme-muted">Drag and drop files here, or click to browse</p>
|
||||
<p class="mt-1 text-xs text-theme-muted">Supports .txt, .md, .json, and other text files</p>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".txt,.md,.json,.csv,.xml,.html"
|
||||
onchange={handleFileSelect}
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Documents List -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-theme-primary">Documents</h3>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<svg class="h-8 w-8 animate-spin text-theme-muted" 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>
|
||||
</div>
|
||||
{:else if documents.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.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" />
|
||||
</svg>
|
||||
<h4 class="mt-4 text-sm font-medium text-theme-muted">No documents yet</h4>
|
||||
<p class="mt-1 text-sm text-theme-muted">Upload documents to build your knowledge base</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each documents as doc (doc.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-400" 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>
|
||||
<div>
|
||||
<h4 class="font-medium text-theme-primary">{doc.name}</h4>
|
||||
<p class="text-xs text-theme-muted">{formatSize(doc.size)} · {doc.chunkCount} chunks · Added {formatDate(doc.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDelete(doc)}
|
||||
class="rounded p-2 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
|
||||
aria-label="Delete document"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info Section -->
|
||||
<section class="mt-8 rounded-lg border border-theme bg-theme-secondary/50 p-4">
|
||||
<h4 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 RAG Works
|
||||
</h4>
|
||||
<p class="mt-2 text-sm text-theme-muted">
|
||||
Documents are split into chunks and converted to embeddings. When you ask a question,
|
||||
relevant chunks are found by similarity search and included in the AI's context.
|
||||
</p>
|
||||
{#if !modelsState.hasEmbeddingModel}
|
||||
<p class="mt-2 text-sm text-amber-400">
|
||||
<strong>No embedding model found.</strong> Install one to use the knowledge base:
|
||||
<code class="ml-1 rounded bg-theme-tertiary px-1 text-theme-muted">ollama pull nomic-embed-text</code>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-emerald-400">
|
||||
Embedding model available: {modelsState.embeddingModels[0]?.name}
|
||||
{#if modelsState.embeddingModels.length > 1}
|
||||
<span class="text-theme-muted">(+{modelsState.embeddingModels.length - 1} more)</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
373
frontend/src/lib/components/settings/MemoryTab.svelte
Normal file
373
frontend/src/lib/components/settings/MemoryTab.svelte
Normal file
@@ -0,0 +1,373 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* MemoryTab - Model parameters, embedding model, auto-compact, and model-prompt defaults
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { modelsState, 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 { PARAMETER_RANGES, PARAMETER_LABELS, PARAMETER_DESCRIPTIONS, AUTO_COMPACT_RANGES } from '$lib/types/settings';
|
||||
import { EMBEDDING_MODELS } from '$lib/memory/embeddings';
|
||||
|
||||
// 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> {
|
||||
if (promptId === null) {
|
||||
await modelPromptMappingsState.removeMapping(modelName);
|
||||
} else {
|
||||
await modelPromptMappingsState.setMapping(modelName, promptId);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the currently mapped prompt ID for a model
|
||||
function getMappedPromptId(modelName: string): string | undefined {
|
||||
return modelPromptMappingsState.getMapping(modelName);
|
||||
}
|
||||
|
||||
// Get current model defaults for reset functionality
|
||||
const currentModelDefaults = $derived(
|
||||
modelsState.selectedId ? modelsState.getModelDefaults(modelsState.selectedId) : undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Memory Management 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-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">
|
||||
<!-- Embedding Model Selector -->
|
||||
<div class="pb-4 border-b border-theme">
|
||||
<label for="embedding-model" class="text-sm font-medium text-theme-secondary">Embedding Model</label>
|
||||
<p class="text-xs text-theme-muted mb-2">Model used for semantic search and conversation indexing</p>
|
||||
<select
|
||||
id="embedding-model"
|
||||
value={settingsState.embeddingModel}
|
||||
onchange={(e) => settingsState.updateEmbeddingModel(e.currentTarget.value)}
|
||||
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 EMBEDDING_MODELS as model}
|
||||
<option value={model}>{model}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if !modelsState.hasEmbeddingModel}
|
||||
<p class="mt-2 text-xs text-amber-400">
|
||||
No embedding model installed. Run <code class="bg-theme-tertiary px-1 rounded text-theme-muted">ollama pull {settingsState.embeddingModel}</code> to enable semantic search.
|
||||
</p>
|
||||
{:else}
|
||||
{@const selectedInstalled = modelsState.embeddingModels.some(m => m.name.includes(settingsState.embeddingModel.split(':')[0]))}
|
||||
{#if !selectedInstalled}
|
||||
<p class="mt-2 text-xs text-amber-400">
|
||||
Selected model not installed. Run <code class="bg-theme-tertiary px-1 rounded text-theme-muted">ollama pull {settingsState.embeddingModel}</code> or select an installed model.
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
Installed: {modelsState.embeddingModels.map(m => m.name).join(', ')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-emerald-400">
|
||||
Model installed and ready.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Model Parameters 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-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>
|
||||
|
||||
<!-- Model-Prompt Defaults 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-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>
|
||||
</div>
|
||||
966
frontend/src/lib/components/settings/ModelsTab.svelte
Normal file
966
frontend/src/lib/components/settings/ModelsTab.svelte
Normal file
@@ -0,0 +1,966 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ModelsTab - Model browser and management
|
||||
* Browse and search models from ollama.com, manage local models
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { modelRegistry } from '$lib/stores/model-registry.svelte';
|
||||
import { localModelsState } from '$lib/stores/local-models.svelte';
|
||||
import { modelsState } from '$lib/stores/models.svelte';
|
||||
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('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleSearchInput(e: Event): void {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
searchInput = value;
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
modelRegistry.search(value);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleTypeFilter(type: 'official' | 'community' | ''): void {
|
||||
modelRegistry.filterByType(type);
|
||||
}
|
||||
|
||||
// Selected model for details panel
|
||||
let selectedModel = $state<RemoteModel | null>(null);
|
||||
let selectedTag = $state<string>('');
|
||||
let pulling = $state(false);
|
||||
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);
|
||||
|
||||
async function handleSelectModel(model: RemoteModel): Promise<void> {
|
||||
selectedModel = model;
|
||||
selectedTag = model.tags[0] || '';
|
||||
pullProgress = null;
|
||||
pullError = null;
|
||||
capabilitiesVerified = false;
|
||||
|
||||
if (!model.tagSizes || Object.keys(model.tagSizes).length === 0) {
|
||||
loadingSizes = true;
|
||||
try {
|
||||
const updatedModel = await fetchTagSizes(model.slug);
|
||||
selectedModel = { ...model, tagSizes: updatedModel.tagSizes };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tag sizes:', err);
|
||||
} finally {
|
||||
loadingSizes = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const realCapabilities = await modelsState.fetchCapabilities(model.slug);
|
||||
if (modelsState.hasCapability(model.slug, 'completion') || realCapabilities.length > 0) {
|
||||
selectedModel = { ...selectedModel!, capabilities: realCapabilities };
|
||||
capabilitiesVerified = true;
|
||||
}
|
||||
} catch {
|
||||
capabilitiesVerified = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetails(): void {
|
||||
selectedModel = null;
|
||||
selectedTag = '';
|
||||
pullProgress = null;
|
||||
pullError = null;
|
||||
}
|
||||
|
||||
async function pullModel(): Promise<void> {
|
||||
if (!selectedModel || pulling) return;
|
||||
|
||||
const modelName = selectedTag
|
||||
? `${selectedModel.slug}:${selectedTag}`
|
||||
: selectedModel.slug;
|
||||
|
||||
pulling = true;
|
||||
pullError = null;
|
||||
pullProgress = { status: 'Starting pull...' };
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/ollama/api/pull', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: modelName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to pull model: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.error) {
|
||||
pullError = data.error;
|
||||
break;
|
||||
}
|
||||
pullProgress = {
|
||||
status: data.status || 'Pulling...',
|
||||
completed: data.completed,
|
||||
total: data.total
|
||||
};
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pullError) {
|
||||
pullProgress = { status: 'Pull complete!' };
|
||||
await modelsState.refresh();
|
||||
modelsState.select(modelName);
|
||||
}
|
||||
} catch (err) {
|
||||
pullError = err instanceof Error ? err.message : 'Failed to pull model';
|
||||
} finally {
|
||||
pulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
return `${value.toFixed(i > 1 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
let deleteConfirm = $state<string | null>(null);
|
||||
let deleting = $state(false);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
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);
|
||||
|
||||
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> {
|
||||
const info = await modelInfoService.getModelInfo(modelName);
|
||||
if (!info.systemPrompt) return;
|
||||
|
||||
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;
|
||||
localModelsState.refresh();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
modelInfoCache = newCache;
|
||||
}
|
||||
|
||||
function hasEmbeddedPrompt(modelName: string): boolean {
|
||||
const info = modelInfoCache.get(modelName);
|
||||
return info?.systemPrompt !== null && info?.systemPrompt !== undefined && info.systemPrompt.length > 0;
|
||||
}
|
||||
|
||||
async function deleteModel(modelName: string): Promise<void> {
|
||||
if (deleting) return;
|
||||
|
||||
deleting = true;
|
||||
deleteError = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/ollama/api/delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: modelName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || `Failed to delete: ${response.statusText}`);
|
||||
}
|
||||
|
||||
await localModelsState.refresh();
|
||||
await modelsState.refresh();
|
||||
deleteConfirm = null;
|
||||
} catch (err) {
|
||||
deleteError = err instanceof Error ? err.message : 'Failed to delete model';
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
let activeTab = $state<'local' | 'browse'>('local');
|
||||
|
||||
let localSearchInput = $state('');
|
||||
let localSearchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleLocalSearchInput(e: Event): void {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
localSearchInput = value;
|
||||
|
||||
if (localSearchTimeout) clearTimeout(localSearchTimeout);
|
||||
localSearchTimeout = setTimeout(() => {
|
||||
localModelsState.search(value);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (localModelsState.models.length > 0) {
|
||||
fetchModelInfoForLocalModels();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
localModelsState.init();
|
||||
modelRegistry.init();
|
||||
modelsState.refresh().then(() => {
|
||||
modelsState.fetchAllCapabilities();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full overflow-hidden">
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-theme-primary">Models</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Manage local models and browse ollama.com
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
{#if activeTab === 'browse' && modelRegistry.syncStatus}
|
||||
<div class="text-right text-xs text-theme-muted">
|
||||
<div>{modelRegistry.syncStatus.modelCount} models cached</div>
|
||||
<div>Last sync: {formatDate(modelRegistry.syncStatus.lastSync ?? undefined)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if activeTab === 'browse'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.sync()}
|
||||
disabled={modelRegistry.syncing}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if modelRegistry.syncing}
|
||||
<svg class="h-4 w-4 animate-spin" 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>
|
||||
<span>Syncing...</span>
|
||||
{: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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Sync Models</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelOperationsState.openPullDialog()}
|
||||
class="flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-sky-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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Pull Model</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => localModelsState.checkUpdates()}
|
||||
disabled={localModelsState.isCheckingUpdates}
|
||||
class="flex items-center gap-2 rounded-lg border border-amber-700 bg-amber-900/20 px-4 py-2 text-sm font-medium text-amber-300 transition-colors hover:bg-amber-900/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if localModelsState.isCheckingUpdates}
|
||||
<svg class="h-4 w-4 animate-spin" 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>
|
||||
<span>Checking...</span>
|
||||
{: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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<span>Check Updates</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => localModelsState.refresh()}
|
||||
disabled={localModelsState.loading}
|
||||
class="flex items-center gap-2 rounded-lg border border-theme bg-theme-secondary px-4 py-2 text-sm font-medium text-theme-secondary transition-colors hover:bg-theme-tertiary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if localModelsState.loading}
|
||||
<svg class="h-4 w-4 animate-spin" 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>
|
||||
{: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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6 flex border-b border-theme">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => activeTab = 'local'}
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'local'
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
<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 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
Local Models
|
||||
<span class="rounded-full bg-theme-tertiary px-2 py-0.5 text-xs">{localModelsState.total}</span>
|
||||
{#if localModelsState.updatesAvailable > 0}
|
||||
<span class="rounded-full bg-amber-600 px-2 py-0.5 text-xs text-theme-primary" title="{localModelsState.updatesAvailable} update{localModelsState.updatesAvailable !== 1 ? 's' : ''} available">
|
||||
{localModelsState.updatesAvailable}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => activeTab = 'browse'}
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'browse'
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
<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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
Browse ollama.com
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Local Models Tab -->
|
||||
{#if activeTab === 'local'}
|
||||
{#if deleteError}
|
||||
<div class="mb-4 rounded-lg border border-red-900/50 bg-red-900/20 p-4">
|
||||
<div class="flex items-center gap-2 text-red-400">
|
||||
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{deleteError}</span>
|
||||
<button type="button" onclick={() => deleteError = null} class="ml-auto text-red-400 hover:text-red-300">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Local Models Search/Filter Bar -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-4">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={localSearchInput}
|
||||
oninput={handleLocalSearchInput}
|
||||
placeholder="Search local models..."
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-theme-primary placeholder-theme-placeholder focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if localModelsState.families.length > 0}
|
||||
<select
|
||||
value={localModelsState.familyFilter}
|
||||
onchange={(e) => localModelsState.filterByFamily((e.target as HTMLSelectElement).value)}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 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 localModelsState.families as family}
|
||||
<option value={family}>{family}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<select
|
||||
value={localModelsState.sortBy}
|
||||
onchange={(e) => localModelsState.setSort((e.target as HTMLSelectElement).value as import('$lib/api/model-registry').LocalModelSortOption)}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="name_asc">Name A-Z</option>
|
||||
<option value="name_desc">Name Z-A</option>
|
||||
<option value="size_desc">Largest</option>
|
||||
<option value="size_asc">Smallest</option>
|
||||
<option value="modified_desc">Recently Modified</option>
|
||||
<option value="modified_asc">Oldest Modified</option>
|
||||
</select>
|
||||
|
||||
{#if localModelsState.searchQuery || localModelsState.familyFilter || localModelsState.sortBy !== 'name_asc'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { localModelsState.clearFilters(); localSearchInput = ''; }}
|
||||
class="text-sm text-theme-muted hover:text-theme-primary"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if localModelsState.loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="animate-pulse rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="h-5 w-48 rounded bg-theme-tertiary"></div>
|
||||
<div class="h-5 w-20 rounded bg-theme-tertiary"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if localModelsState.models.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-12 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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-theme-muted">
|
||||
{#if localModelsState.searchQuery || localModelsState.familyFilter}
|
||||
No models match your filters
|
||||
{:else}
|
||||
No local models
|
||||
{/if}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
{#if localModelsState.searchQuery || localModelsState.familyFilter}
|
||||
Try adjusting your search or filters
|
||||
{:else}
|
||||
Browse ollama.com to pull models
|
||||
{/if}
|
||||
</p>
|
||||
{#if !localModelsState.searchQuery && !localModelsState.familyFilter}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => activeTab = 'browse'}
|
||||
class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700"
|
||||
>
|
||||
Browse Models
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{: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">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="font-medium text-theme-primary">{model.name}</h3>
|
||||
{#if model.name === modelsState.selectedId}
|
||||
<span class="rounded bg-blue-900/50 px-2 py-0.5 text-xs text-blue-300">Selected</span>
|
||||
{/if}
|
||||
{#if localModelsState.hasUpdate(model.name)}
|
||||
<span class="rounded bg-amber-600 px-2 py-0.5 text-xs font-medium text-theme-primary" title="Update available">
|
||||
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>
|
||||
<span>Family: {model.family}</span>
|
||||
<span>Parameters: {model.parameterSize}</span>
|
||||
<span>Quantization: {model.quantizationLevel}</span>
|
||||
</div>
|
||||
{#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}
|
||||
<span class="text-sm text-theme-muted">Delete?</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteModel(model.name)}
|
||||
disabled={deleting}
|
||||
class="rounded bg-red-600 px-3 py-1 text-sm font-medium text-theme-primary hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteConfirm = null}
|
||||
disabled={deleting}
|
||||
class="rounded bg-theme-tertiary px-3 py-1 text-sm font-medium text-theme-secondary hover:bg-theme-secondary disabled:opacity-50"
|
||||
>
|
||||
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}
|
||||
class="rounded p-2 text-theme-muted opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-red-400 group-hover:opacity-100"
|
||||
title="Delete model"
|
||||
>
|
||||
<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="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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if localModelsState.totalPages > 1}
|
||||
<div class="mt-6 flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => localModelsState.prevPage()}
|
||||
disabled={!localModelsState.hasPrevPage}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary hover:bg-theme-tertiary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span class="px-3 text-sm text-theme-muted">
|
||||
Page {localModelsState.currentPage + 1} of {localModelsState.totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => localModelsState.nextPage()}
|
||||
disabled={!localModelsState.hasNextPage}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary hover:bg-theme-tertiary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Browse Tab - Search and Filters -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-4">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
oninput={handleSearchInput}
|
||||
placeholder="Search models..."
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-theme-primary placeholder-theme-placeholder focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex rounded-lg border border-theme bg-theme-secondary p-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTypeFilter('')}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === ''
|
||||
? 'bg-theme-tertiary text-theme-primary'
|
||||
: 'text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTypeFilter('official')}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === 'official'
|
||||
? 'bg-blue-600 text-theme-primary'
|
||||
: 'text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
Official
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTypeFilter('community')}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === 'community'
|
||||
? 'bg-theme-tertiary text-theme-primary'
|
||||
: 'text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
Community
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="sort-select" class="text-sm text-theme-muted">Sort:</label>
|
||||
<select
|
||||
id="sort-select"
|
||||
value={modelRegistry.sortBy}
|
||||
onchange={(e) => modelRegistry.setSort((e.target as HTMLSelectElement).value as import('$lib/api/model-registry').ModelSortOption)}
|
||||
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="pulls_desc">Most Popular</option>
|
||||
<option value="pulls_asc">Least Popular</option>
|
||||
<option value="name_asc">Name A-Z</option>
|
||||
<option value="name_desc">Name Z-A</option>
|
||||
<option value="updated_desc">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-theme-muted">
|
||||
{modelRegistry.total} model{modelRegistry.total !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capability Filters -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-theme-muted">Capabilities:</span>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('vision')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('vision') ? 'bg-purple-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>👁</span><span>Vision</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('tools')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('tools') ? 'bg-blue-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>🔧</span><span>Tools</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('thinking')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('thinking') ? 'bg-pink-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>🧠</span><span>Thinking</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('embedding')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('embedding') ? 'bg-amber-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>📊</span><span>Embedding</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('cloud')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('cloud') ? 'bg-cyan-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>☁️</span><span>Cloud</span>
|
||||
</button>
|
||||
<span class="ml-2 text-xs text-theme-muted opacity-60">from ollama.com</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 -->
|
||||
<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="text-sm text-theme-muted hover:text-theme-primary"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if modelRegistry.error}
|
||||
<div class="mb-6 rounded-lg border border-red-900/50 bg-red-900/20 p-4">
|
||||
<div class="flex items-center gap-2 text-red-400">
|
||||
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{modelRegistry.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if modelRegistry.loading}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="h-5 w-32 rounded bg-theme-tertiary"></div>
|
||||
<div class="h-5 w-16 rounded bg-theme-tertiary"></div>
|
||||
</div>
|
||||
<div class="mt-3 h-4 w-full rounded bg-theme-tertiary"></div>
|
||||
<div class="mt-2 h-4 w-2/3 rounded bg-theme-tertiary"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if modelRegistry.models.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-12 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="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611l-.628.105a9.002 9.002 0 01-9.014 0l-.628-.105c-1.717-.293-2.3-2.379-1.067-3.61L5 14.5" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-theme-muted">No models found</h3>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
{#if modelRegistry.searchQuery || modelRegistry.modelType}
|
||||
Try adjusting your search or filters
|
||||
{:else}
|
||||
Click "Sync Models" to fetch models from ollama.com
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each modelRegistry.models as model (model.slug)}
|
||||
<ModelCard {model} onSelect={handleSelectModel} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if modelRegistry.totalPages > 1}
|
||||
<div class="mt-6 flex items-center justify-center gap-2">
|
||||
<button type="button" onclick={() => modelRegistry.prevPage()} disabled={!modelRegistry.hasPrevPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-sm text-theme-muted">Page {modelRegistry.currentPage + 1} of {modelRegistry.totalPages}</span>
|
||||
<button type="button" onclick={() => modelRegistry.nextPage()} disabled={!modelRegistry.hasNextPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Model Details Sidebar -->
|
||||
{#if selectedModel}
|
||||
<div class="w-80 flex-shrink-0 overflow-y-auto border-l border-theme bg-theme-secondary p-4">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<h3 class="text-lg font-semibold text-theme-primary">{selectedModel.name}</h3>
|
||||
<button type="button" onclick={closeDetails} class="rounded p-1 text-theme-muted 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="mb-4">
|
||||
<span class="rounded px-2 py-1 text-xs {selectedModel.modelType === 'official' ? 'bg-blue-900/50 text-blue-300' : 'bg-theme-tertiary text-theme-muted'}">
|
||||
{selectedModel.modelType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if selectedModel.description}
|
||||
<div class="mb-4">
|
||||
<h4 class="mb-2 text-sm font-medium text-theme-secondary">Description</h4>
|
||||
<p class="text-sm text-theme-muted">{selectedModel.description}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedModel.capabilities.length > 0}
|
||||
<div class="mb-4">
|
||||
<h4 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">✓ 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">unverified</span>
|
||||
{/if}
|
||||
</h4>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pull Section -->
|
||||
<div class="mb-4">
|
||||
<h4 class="mb-2 text-sm font-medium text-theme-secondary">Pull Model</h4>
|
||||
{#if selectedModel.tags.length > 0}
|
||||
<select bind:value={selectedTag} disabled={pulling} class="mb-2 w-full rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary disabled:opacity-50">
|
||||
{#each selectedModel.tags as tag}
|
||||
{@const size = selectedModel.tagSizes?.[tag]}
|
||||
<option value={tag}>{selectedModel.slug}:{tag} {size ? `(${formatBytes(size)})` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button type="button" onclick={pullModel} disabled={pulling} class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700 disabled:opacity-50">
|
||||
{#if pulling}
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></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>
|
||||
Pulling...
|
||||
{:else}
|
||||
Pull Model
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if pullProgress}
|
||||
<div class="mt-2 text-xs text-theme-muted">{pullProgress.status}</div>
|
||||
{#if pullProgress.completed !== undefined && pullProgress.total}
|
||||
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-theme-tertiary">
|
||||
<div class="h-full bg-blue-500 transition-all" style="width: {Math.round((pullProgress.completed / pullProgress.total) * 100)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if pullError}
|
||||
<div class="mt-2 rounded border border-red-900/50 bg-red-900/20 p-2 text-xs text-red-400">{pullError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<a href={selectedModel.url} target="_blank" rel="noopener noreferrer" class="flex w-full items-center justify-center gap-2 rounded-lg border border-theme bg-theme-secondary px-4 py-2 text-sm text-theme-secondary hover:bg-theme-tertiary">
|
||||
View on ollama.com
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PullModelDialog />
|
||||
<ModelEditorDialog isOpen={modelEditorOpen} mode={modelEditorMode} editingModel={editingModelName} currentSystemPrompt={editingSystemPrompt} baseModel={editingBaseModel} onClose={closeModelEditor} />
|
||||
|
||||
{#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">
|
||||
<div class="mx-auto max-w-4xl space-y-3">
|
||||
<h3 class="text-sm font-medium text-theme-secondary">Active Downloads</h3>
|
||||
{#each [...modelOperationsState.activePulls.entries()] as [name, pull]}
|
||||
<div class="rounded-lg bg-theme-primary/50 p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-medium text-theme-secondary">{name}</span>
|
||||
<button type="button" onclick={() => modelOperationsState.cancelPull(name)} class="text-xs text-red-400 hover:text-red-300">Cancel</button>
|
||||
</div>
|
||||
<div class="mb-1 flex items-center gap-3">
|
||||
<div class="h-2 flex-1 overflow-hidden rounded-full bg-theme-tertiary">
|
||||
<div class="h-full bg-sky-500 transition-all" style="width: {pull.progress.percent}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-theme-muted">{pull.progress.percent}%</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-theme-muted">
|
||||
<span>{pull.progress.status}</span>
|
||||
{#if pull.progress.speed}
|
||||
<span>{modelOperationsState.formatBytes(pull.progress.speed)}/s</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
446
frontend/src/lib/components/settings/PromptsTab.svelte
Normal file
446
frontend/src/lib/components/settings/PromptsTab.svelte
Normal file
@@ -0,0 +1,446 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* PromptsTab - System prompts management
|
||||
*/
|
||||
import { promptsState, type Prompt } from '$lib/stores';
|
||||
import {
|
||||
getAllPromptTemplates,
|
||||
getPromptCategories,
|
||||
categoryInfo,
|
||||
type PromptTemplate,
|
||||
type PromptCategory
|
||||
} from '$lib/prompts/templates';
|
||||
|
||||
type Tab = 'my-prompts' | 'browse-templates';
|
||||
let activeTab = $state<Tab>('my-prompts');
|
||||
|
||||
let showEditor = $state(false);
|
||||
let editingPrompt = $state<Prompt | null>(null);
|
||||
|
||||
let formName = $state('');
|
||||
let formDescription = $state('');
|
||||
let formContent = $state('');
|
||||
let formIsDefault = $state(false);
|
||||
let formTargetCapabilities = $state<string[]>([]);
|
||||
let isSaving = $state(false);
|
||||
|
||||
let selectedCategory = $state<PromptCategory | 'all'>('all');
|
||||
let previewTemplate = $state<PromptTemplate | null>(null);
|
||||
let addingTemplateId = $state<string | null>(null);
|
||||
|
||||
const templates = getAllPromptTemplates();
|
||||
const categories = getPromptCategories();
|
||||
|
||||
const filteredTemplates = $derived(
|
||||
selectedCategory === 'all'
|
||||
? templates
|
||||
: templates.filter((t) => t.category === selectedCategory)
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function openEditEditor(prompt: Prompt): void {
|
||||
editingPrompt = prompt;
|
||||
formName = prompt.name;
|
||||
formDescription = prompt.description;
|
||||
formContent = prompt.content;
|
||||
formIsDefault = prompt.isDefault;
|
||||
formTargetCapabilities = prompt.targetCapabilities ?? [];
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function closeEditor(): void {
|
||||
showEditor = false;
|
||||
editingPrompt = null;
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!formName.trim() || !formContent.trim()) return;
|
||||
|
||||
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,
|
||||
targetCapabilities: capabilities ?? []
|
||||
});
|
||||
} else {
|
||||
await promptsState.add({
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim(),
|
||||
content: formContent,
|
||||
isDefault: formIsDefault,
|
||||
targetCapabilities: capabilities
|
||||
});
|
||||
}
|
||||
closeEditor();
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetDefault(prompt: Prompt): Promise<void> {
|
||||
if (prompt.isDefault) {
|
||||
await promptsState.clearDefault();
|
||||
} else {
|
||||
await promptsState.setDefault(prompt.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetActive(prompt: Prompt): void {
|
||||
if (promptsState.activePromptId === prompt.id) {
|
||||
promptsState.setActive(null);
|
||||
} else {
|
||||
promptsState.setActive(prompt.id);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
activeTab = 'my-prompts';
|
||||
} finally {
|
||||
addingTemplateId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-theme-primary">System Prompts</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Create and manage system prompt templates for conversations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'my-prompts'}
|
||||
<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>
|
||||
{/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' : ''}">
|
||||
{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'}
|
||||
{#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: <strong class="text-blue-300">{promptsState.activePrompt.name}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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 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 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>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
<button type="button" onclick={() => openEditEditor(prompt)} class="rounded p-1.5 text-theme-muted 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>
|
||||
<button type="button" onclick={() => handleDelete(prompt)} class="rounded p-1.5 text-theme-muted 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}
|
||||
{/if}
|
||||
|
||||
<!-- Browse Templates Tab -->
|
||||
{#if activeTab === 'browse-templates'}
|
||||
<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>
|
||||
|
||||
<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 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}
|
||||
</div>
|
||||
|
||||
<!-- Editor Modal -->
|
||||
{#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(); }} role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-2xl rounded-xl bg-theme-secondary shadow-xl">
|
||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||
<h3 class="text-lg font-semibold text-theme-primary">{editingPrompt ? 'Edit Prompt' : 'Create Prompt'}</h3>
|
||||
<button type="button" onclick={closeEditor} class="rounded p-1 text-theme-muted 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>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }} class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="prompt-name" class="mb-1 block text-sm font-medium text-theme-secondary">Name <span class="text-red-400">*</span></label>
|
||||
<input id="prompt-name" type="text" bind:value={formName} placeholder="e.g., Code Reviewer" class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="prompt-description" class="mb-1 block text-sm font-medium text-theme-secondary">Description</label>
|
||||
<input id="prompt-description" type="text" bind:value={formDescription} placeholder="Brief description" class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="prompt-content" class="mb-1 block text-sm font-medium text-theme-secondary">System Prompt <span class="text-red-400">*</span></label>
|
||||
<textarea id="prompt-content" bind:value={formContent} placeholder="You are a helpful assistant that..." rows="8" 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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" required></textarea>
|
||||
<p class="mt-1 text-xs text-theme-muted">{formContent.length} characters</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="prompt-default" type="checkbox" bind:checked={formIsDefault} class="h-4 w-4 rounded border-theme-subtle bg-theme-tertiary text-blue-600 focus:ring-blue-500 focus:ring-offset-theme" />
|
||||
<label for="prompt-default" class="text-sm text-theme-secondary">Set as default for new chats</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-theme-secondary">Auto-use for model types</label>
|
||||
<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>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button type="button" onclick={closeEditor} class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary hover:bg-theme-tertiary">Cancel</button>
|
||||
<button type="submit" disabled={isSaving || !formName.trim() || !formContent.trim()} class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{isSaving ? 'Saving...' : editingPrompt ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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; }} 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>
|
||||
<h3 class="text-lg font-semibold text-theme-primary">{previewTemplate.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={() => (previewTemplate = null)} class="rounded p-1 text-theme-muted 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 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 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}
|
||||
70
frontend/src/lib/components/settings/SettingsTabs.svelte
Normal file
70
frontend/src/lib/components/settings/SettingsTabs.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" module>
|
||||
/**
|
||||
* SettingsTabs - Horizontal tab navigation for Settings Hub
|
||||
*/
|
||||
export type SettingsTab = 'general' | 'models' | 'prompts' | 'tools' | 'knowledge' | 'memory';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Tab {
|
||||
id: SettingsTab;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'models', label: 'Models', icon: 'cpu' },
|
||||
{ id: 'prompts', label: 'Prompts', icon: 'message' },
|
||||
{ id: 'tools', label: 'Tools', icon: 'wrench' },
|
||||
{ id: 'knowledge', label: 'Knowledge', icon: 'book' },
|
||||
{ id: 'memory', label: 'Memory', icon: 'brain' }
|
||||
];
|
||||
|
||||
// Get active tab from URL, default to 'general'
|
||||
let activeTab = $derived<SettingsTab>(
|
||||
($page.url.searchParams.get('tab') as SettingsTab) || 'general'
|
||||
);
|
||||
</script>
|
||||
|
||||
<nav class="flex gap-1 overflow-x-auto">
|
||||
{#each tabs as tab}
|
||||
<a
|
||||
href="/settings?tab={tab.id}"
|
||||
class="flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium transition-colors
|
||||
{activeTab === tab.id
|
||||
? 'border-violet-500 text-violet-400'
|
||||
: 'border-transparent text-theme-muted hover:border-theme hover:text-theme-primary'}"
|
||||
>
|
||||
{#if tab.icon === 'settings'}
|
||||
<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="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.559.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.272-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'cpu'}
|
||||
<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="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'message'}
|
||||
<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="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'wrench'}
|
||||
<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.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'book'}
|
||||
<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 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'brain'}
|
||||
<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="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
511
frontend/src/lib/components/settings/ToolsTab.svelte
Normal file
511
frontend/src/lib/components/settings/ToolsTab.svelte
Normal file
@@ -0,0 +1,511 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ToolsTab - Enhanced tools management with better visuals
|
||||
*/
|
||||
import { toolsState } from '$lib/stores';
|
||||
import type { ToolDefinition, CustomTool } from '$lib/tools';
|
||||
import { ToolEditor } from '$lib/components/tools';
|
||||
|
||||
let showEditor = $state(false);
|
||||
let editingTool = $state<CustomTool | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let expandedDescriptions = $state<Set<string>>(new Set());
|
||||
|
||||
function openCreateEditor(): void {
|
||||
editingTool = null;
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function openEditEditor(tool: CustomTool): void {
|
||||
editingTool = tool;
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function handleSaveTool(tool: CustomTool): void {
|
||||
if (editingTool) {
|
||||
toolsState.updateCustomTool(tool.id, tool);
|
||||
} else {
|
||||
toolsState.addCustomTool(tool);
|
||||
}
|
||||
showEditor = false;
|
||||
editingTool = null;
|
||||
}
|
||||
|
||||
function handleDeleteTool(tool: CustomTool): void {
|
||||
if (confirm(`Delete "${tool.name}"? This cannot be undone.`)) {
|
||||
toolsState.removeCustomTool(tool.id);
|
||||
}
|
||||
}
|
||||
|
||||
const allTools = $derived(toolsState.getAllToolsWithState());
|
||||
const builtinTools = $derived(allTools.filter(t => t.isBuiltin));
|
||||
|
||||
// Stats
|
||||
const stats = $derived({
|
||||
total: builtinTools.length + toolsState.customTools.length,
|
||||
enabled: builtinTools.filter(t => t.enabled).length + toolsState.customTools.filter(t => t.enabled).length,
|
||||
builtin: builtinTools.length,
|
||||
custom: toolsState.customTools.length
|
||||
});
|
||||
|
||||
// Filtered tools based on search
|
||||
const filteredBuiltinTools = $derived(
|
||||
searchQuery.trim()
|
||||
? builtinTools.filter(t =>
|
||||
t.definition.function.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.definition.function.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: builtinTools
|
||||
);
|
||||
|
||||
const filteredCustomTools = $derived(
|
||||
searchQuery.trim()
|
||||
? toolsState.customTools.filter(t =>
|
||||
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: toolsState.customTools
|
||||
);
|
||||
|
||||
function toggleTool(name: string): void {
|
||||
toolsState.toggleTool(name);
|
||||
}
|
||||
|
||||
function toggleGlobalTools(): void {
|
||||
toolsState.toggleToolsEnabled();
|
||||
}
|
||||
|
||||
function toggleDescription(toolName: string): void {
|
||||
const newSet = new Set(expandedDescriptions);
|
||||
if (newSet.has(toolName)) {
|
||||
newSet.delete(toolName);
|
||||
} else {
|
||||
newSet.add(toolName);
|
||||
}
|
||||
expandedDescriptions = newSet;
|
||||
}
|
||||
|
||||
// Get icon for built-in tool based on name
|
||||
function getToolIcon(name: string): { icon: string; color: string } {
|
||||
const icons: Record<string, { icon: string; color: string }> = {
|
||||
'get_current_time': { icon: 'clock', color: 'text-amber-400' },
|
||||
'calculate': { icon: 'calculator', color: 'text-blue-400' },
|
||||
'fetch_url': { icon: 'globe', color: 'text-cyan-400' },
|
||||
'get_location': { icon: 'location', color: 'text-rose-400' },
|
||||
'web_search': { icon: 'search', color: 'text-emerald-400' }
|
||||
};
|
||||
return icons[name] || { icon: 'tool', color: 'text-gray-400' };
|
||||
}
|
||||
|
||||
// Get implementation icon
|
||||
function getImplementationIcon(impl: string): { icon: string; color: string; bg: string } {
|
||||
const icons: Record<string, { icon: string; color: string; bg: string }> = {
|
||||
'javascript': { icon: 'js', color: 'text-yellow-300', bg: 'bg-yellow-900/30' },
|
||||
'python': { icon: 'py', color: 'text-blue-300', bg: 'bg-blue-900/30' },
|
||||
'http': { icon: 'http', color: 'text-purple-300', bg: 'bg-purple-900/30' }
|
||||
};
|
||||
return icons[impl] || { icon: '?', color: 'text-gray-300', bg: 'bg-gray-900/30' };
|
||||
}
|
||||
|
||||
// Format parameters with type info
|
||||
function getParameters(def: ToolDefinition): Array<{ name: string; type: string; required: boolean; description?: string }> {
|
||||
const params = def.function.parameters;
|
||||
if (!params.properties) return [];
|
||||
|
||||
return Object.entries(params.properties).map(([name, prop]) => ({
|
||||
name,
|
||||
type: prop.type,
|
||||
required: params.required?.includes(name) ?? false,
|
||||
description: prop.description
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if description is long
|
||||
function isLongDescription(text: string): boolean {
|
||||
return text.length > 150;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-theme-primary">Tools</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Extend AI capabilities with built-in and custom tools
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-theme-muted">Tools enabled</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleGlobalTools}
|
||||
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-violet-500 focus:ring-offset-2 focus:ring-offset-theme-primary {toolsState.toolsEnabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
|
||||
role="switch"
|
||||
aria-checked={toolsState.toolsEnabled}
|
||||
>
|
||||
<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 {toolsState.toolsEnabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mb-6 grid grid-cols-4 gap-4">
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Total Tools</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-theme-primary">{stats.total}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Enabled</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-emerald-400">{stats.enabled}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Built-in</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-blue-400">{stats.builtin}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Custom</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-violet-400">{stats.custom}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-6">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search tools..."
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-sm text-theme-primary placeholder:text-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => searchQuery = ''}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-theme-muted hover:text-theme-primary"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Built-in Tools -->
|
||||
<section class="mb-8">
|
||||
<h3 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Built-in Tools
|
||||
<span class="text-sm font-normal text-theme-muted">({filteredBuiltinTools.length})</span>
|
||||
</h3>
|
||||
|
||||
{#if filteredBuiltinTools.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||
<p class="text-sm text-theme-muted">No tools match your search</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each filteredBuiltinTools as tool (tool.definition.function.name)}
|
||||
{@const toolIcon = getToolIcon(tool.definition.function.name)}
|
||||
{@const params = getParameters(tool.definition)}
|
||||
{@const isLong = isLongDescription(tool.definition.function.description)}
|
||||
{@const isExpanded = expandedDescriptions.has(tool.definition.function.name)}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary transition-all {tool.enabled ? '' : 'opacity-50'}">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Tool Icon -->
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-theme-tertiary {toolIcon.color}">
|
||||
{#if toolIcon.icon === 'clock'}
|
||||
<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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{:else if toolIcon.icon === 'calculator'}
|
||||
<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="M15.75 15.75V18m-7.5-6.75h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V13.5zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V18zm2.498-6.75h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V13.5zm0 2.25h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V18zm2.504-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V18zm2.498-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zM8.25 6h7.5v2.25h-7.5V6zM12 2.25c-1.892 0-3.758.11-5.593.322C5.307 2.7 4.5 3.65 4.5 4.757V19.5a2.25 2.25 0 002.25 2.25h10.5a2.25 2.25 0 002.25-2.25V4.757c0-1.108-.806-2.057-1.907-2.185A48.507 48.507 0 0012 2.25z" />
|
||||
</svg>
|
||||
{:else if toolIcon.icon === 'globe'}
|
||||
<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="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
{:else if toolIcon.icon === 'location'}
|
||||
<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="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
{:else if toolIcon.icon === 'search'}
|
||||
<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="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<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.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-mono text-sm font-semibold text-theme-primary">{tool.definition.function.name}</h4>
|
||||
<span class="rounded-full bg-blue-900/40 px-2 py-0.5 text-xs font-medium text-blue-300">built-in</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-theme-muted {isLong && !isExpanded ? 'line-clamp-2' : ''}">
|
||||
{tool.definition.function.description}
|
||||
</p>
|
||||
{#if isLong}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleDescription(tool.definition.function.name)}
|
||||
class="mt-1 text-xs text-violet-400 hover:text-violet-300"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleTool(tool.definition.function.name)}
|
||||
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-blue-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
disabled={!toolsState.toolsEnabled}
|
||||
>
|
||||
<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 {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Parameters -->
|
||||
{#if params.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-2 border-t border-theme pt-3">
|
||||
{#each params as param}
|
||||
<div class="flex items-center gap-1 rounded-md bg-theme-tertiary px-2 py-1" title={param.description || ''}>
|
||||
<span class="font-mono text-xs text-theme-primary">{param.name}</span>
|
||||
{#if param.required}
|
||||
<span class="text-xs text-rose-400">*</span>
|
||||
{/if}
|
||||
<span class="text-xs text-theme-muted">:</span>
|
||||
<span class="rounded bg-theme-hover px-1 text-xs text-cyan-400">{param.type}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Custom Tools -->
|
||||
<section>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="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="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
Custom Tools
|
||||
<span class="text-sm font-normal text-theme-muted">({filteredCustomTools.length})</span>
|
||||
</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="flex items-center gap-2 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-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 Tool
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if filteredCustomTools.length === 0 && toolsState.customTools.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="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
<h4 class="mt-4 text-sm font-medium text-theme-secondary">No custom tools yet</h4>
|
||||
<p class="mt-1 text-sm text-theme-muted">Create JavaScript, Python, or HTTP tools to extend AI capabilities</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg border border-violet-500 px-4 py-2 text-sm font-medium text-violet-400 transition-colors hover:bg-violet-900/30"
|
||||
>
|
||||
<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 Tool
|
||||
</button>
|
||||
</div>
|
||||
{:else if filteredCustomTools.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||
<p class="text-sm text-theme-muted">No custom tools match your search</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each filteredCustomTools as tool (tool.id)}
|
||||
{@const implIcon = getImplementationIcon(tool.implementation)}
|
||||
{@const customParams = Object.entries(tool.parameters.properties ?? {})}
|
||||
{@const isLong = isLongDescription(tool.description)}
|
||||
{@const isExpanded = expandedDescriptions.has(tool.id)}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary transition-all {tool.enabled ? '' : 'opacity-50'}">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Implementation Icon -->
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg {implIcon.bg}">
|
||||
{#if tool.implementation === 'javascript'}
|
||||
<span class="font-mono text-sm font-bold {implIcon.color}">JS</span>
|
||||
{:else if tool.implementation === 'python'}
|
||||
<span class="font-mono text-sm font-bold {implIcon.color}">PY</span>
|
||||
{:else}
|
||||
<svg class="h-5 w-5 {implIcon.color}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-mono text-sm font-semibold text-theme-primary">{tool.name}</h4>
|
||||
<span class="rounded-full bg-violet-900/40 px-2 py-0.5 text-xs font-medium text-violet-300">custom</span>
|
||||
<span class="rounded-full {implIcon.bg} px-2 py-0.5 text-xs font-medium {implIcon.color}">{tool.implementation}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-theme-muted {isLong && !isExpanded ? 'line-clamp-2' : ''}">
|
||||
{tool.description}
|
||||
</p>
|
||||
{#if isLong}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleDescription(tool.id)}
|
||||
class="mt-1 text-xs text-violet-400 hover:text-violet-300"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditEditor(tool)}
|
||||
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||
aria-label="Edit tool"
|
||||
>
|
||||
<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDeleteTool(tool)}
|
||||
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
|
||||
aria-label="Delete tool"
|
||||
>
|
||||
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleTool(tool.name)}
|
||||
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-violet-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
disabled={!toolsState.toolsEnabled}
|
||||
>
|
||||
<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 {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameters -->
|
||||
{#if customParams.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-2 border-t border-theme pt-3">
|
||||
{#each customParams as [name, prop]}
|
||||
<div class="flex items-center gap-1 rounded-md bg-theme-tertiary px-2 py-1" title={prop.description || ''}>
|
||||
<span class="font-mono text-xs text-theme-primary">{name}</span>
|
||||
{#if tool.parameters.required?.includes(name)}
|
||||
<span class="text-xs text-rose-400">*</span>
|
||||
{/if}
|
||||
<span class="text-xs text-theme-muted">:</span>
|
||||
<span class="rounded bg-theme-hover px-1 text-xs text-cyan-400">{prop.type}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Info Section -->
|
||||
<section class="mt-8 rounded-lg border border-theme bg-gradient-to-br from-theme-secondary/80 to-theme-secondary/40 p-5">
|
||||
<h4 class="flex items-center gap-2 text-sm 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="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
How Tools Work
|
||||
</h4>
|
||||
<p class="mt-3 text-sm text-theme-muted leading-relaxed">
|
||||
Tools extend the AI's capabilities by allowing it to perform actions beyond text generation.
|
||||
When you ask a question that could benefit from a tool, the AI will automatically select and use the appropriate one.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-lg bg-theme-tertiary/50 p-3">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-yellow-400">
|
||||
<span class="font-mono">JS</span>
|
||||
JavaScript
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">Runs in browser, instant execution</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-theme-tertiary/50 p-3">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-blue-400">
|
||||
<span class="font-mono">PY</span>
|
||||
Python
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">Runs on backend server</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-theme-tertiary/50 p-3">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-purple-400">
|
||||
<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.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
HTTP
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">Calls external APIs</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-theme-muted">
|
||||
<strong class="text-theme-secondary">Note:</strong> Not all models support tool calling. Models like Llama 3.1+, Mistral 7B+, and Qwen have built-in tool support.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ToolEditor
|
||||
isOpen={showEditor}
|
||||
editingTool={editingTool}
|
||||
onClose={() => { showEditor = false; editingTool = null; }}
|
||||
onSave={handleSaveTool}
|
||||
/>
|
||||
13
frontend/src/lib/components/settings/index.ts
Normal file
13
frontend/src/lib/components/settings/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Settings components barrel export
|
||||
*/
|
||||
export { default as SettingsTabs } from './SettingsTabs.svelte';
|
||||
export { default as GeneralTab } from './GeneralTab.svelte';
|
||||
export { default as ModelsTab } from './ModelsTab.svelte';
|
||||
export { default as PromptsTab } from './PromptsTab.svelte';
|
||||
export { default as ToolsTab } from './ToolsTab.svelte';
|
||||
export { default as KnowledgeTab } from './KnowledgeTab.svelte';
|
||||
export { default as MemoryTab } from './MemoryTab.svelte';
|
||||
export { default as ModelParametersPanel } from './ModelParametersPanel.svelte';
|
||||
|
||||
export type { SettingsTab } from './SettingsTabs.svelte';
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SearchModal - Global search modal for conversations and messages
|
||||
* Supports searching both conversation titles and message content
|
||||
* Supports searching conversation titles, message content, and semantic search
|
||||
*/
|
||||
import { goto } from '$app/navigation';
|
||||
import { searchConversations, searchMessages, type MessageSearchResult } from '$lib/storage';
|
||||
import { conversationsState } from '$lib/stores';
|
||||
import type { Conversation } from '$lib/types/conversation';
|
||||
import { searchAllChatHistory, type ChatSearchResult } from '$lib/services/chat-indexer.js';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -17,12 +18,13 @@
|
||||
|
||||
// Search state
|
||||
let searchQuery = $state('');
|
||||
let activeTab = $state<'titles' | 'messages'>('titles');
|
||||
let activeTab = $state<'titles' | 'messages' | 'semantic'>('titles');
|
||||
let isSearching = $state(false);
|
||||
|
||||
// Results
|
||||
let titleResults = $state<Conversation[]>([]);
|
||||
let messageResults = $state<MessageSearchResult[]>([]);
|
||||
let semanticResults = $state<ChatSearchResult[]>([]);
|
||||
|
||||
// Debounce timer
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -41,6 +43,7 @@
|
||||
if (!searchQuery.trim()) {
|
||||
titleResults = [];
|
||||
messageResults = [];
|
||||
semanticResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,10 +51,11 @@
|
||||
isSearching = true;
|
||||
|
||||
try {
|
||||
// Search both in parallel
|
||||
const [titlesResult, messagesResult] = await Promise.all([
|
||||
// Search all three in parallel
|
||||
const [titlesResult, messagesResult, semanticSearchResults] = await Promise.all([
|
||||
searchConversations(searchQuery),
|
||||
searchMessages(searchQuery, { limit: 30 })
|
||||
searchMessages(searchQuery, { limit: 30 }),
|
||||
searchAllChatHistory(searchQuery, undefined, 30, 0.15)
|
||||
]);
|
||||
|
||||
if (titlesResult.success) {
|
||||
@@ -61,6 +65,10 @@
|
||||
if (messagesResult.success) {
|
||||
messageResults = messagesResult.data;
|
||||
}
|
||||
|
||||
semanticResults = semanticSearchResults;
|
||||
} catch (error) {
|
||||
console.error('[SearchModal] Search error:', error);
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
@@ -125,6 +133,7 @@
|
||||
searchQuery = '';
|
||||
titleResults = [];
|
||||
messageResults = [];
|
||||
semanticResults = [];
|
||||
activeTab = 'titles';
|
||||
onClose();
|
||||
}
|
||||
@@ -142,6 +151,7 @@
|
||||
searchQuery = '';
|
||||
titleResults = [];
|
||||
messageResults = [];
|
||||
semanticResults = [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -255,6 +265,20 @@
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'semantic')}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'semantic'
|
||||
? 'border-b-2 border-emerald-500 text-emerald-400'
|
||||
: 'text-theme-muted hover:text-theme-secondary'}"
|
||||
>
|
||||
Semantic
|
||||
{#if semanticResults.length > 0}
|
||||
<span class="ml-1.5 rounded-full bg-theme-secondary px-1.5 py-0.5 text-xs"
|
||||
>{semanticResults.length}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
@@ -312,7 +336,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{:else if activeTab === 'messages'}
|
||||
{#if messageResults.length === 0 && !isSearching}
|
||||
<div class="py-8 text-center text-sm text-theme-muted">
|
||||
No messages found matching "{searchQuery}"
|
||||
@@ -345,6 +369,35 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'semantic'}
|
||||
{#if semanticResults.length === 0 && !isSearching}
|
||||
<div class="py-8 text-center text-sm text-theme-muted">
|
||||
<p>No semantic matches found for "{searchQuery}"</p>
|
||||
<p class="mt-1 text-xs">Semantic search uses AI embeddings to find similar content</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-theme-secondary">
|
||||
{#each semanticResults as result}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateToConversation(result.conversationId)}
|
||||
class="flex w-full flex-col gap-1 px-4 py-3 text-left transition-colors hover:bg-theme-secondary"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
{Math.round(result.similarity * 100)}% match
|
||||
</span>
|
||||
<span class="truncate text-xs text-theme-muted">
|
||||
{result.conversationTitle}
|
||||
</span>
|
||||
</div>
|
||||
<p class="line-clamp-2 text-sm text-theme-secondary">
|
||||
{result.content.slice(0, 200)}{result.content.length > 200 ? '...' : ''}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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';
|
||||
|
||||
@@ -513,6 +513,29 @@ print(json.dumps(result))`;
|
||||
</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}
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
implementation: ToolImplementation;
|
||||
code: string;
|
||||
parameters: JSONSchema;
|
||||
endpoint?: string;
|
||||
httpMethod?: 'GET' | 'POST';
|
||||
isOpen?: boolean;
|
||||
onclose?: () => void;
|
||||
}
|
||||
|
||||
const { implementation, code, parameters, isOpen = false, onclose }: Props = $props();
|
||||
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);
|
||||
@@ -116,8 +118,54 @@
|
||||
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: 'HTTP tools cannot be tested in the editor' };
|
||||
testResult = { success: false, error: 'Unknown implementation type' };
|
||||
}
|
||||
} finally {
|
||||
isRunning = false;
|
||||
@@ -169,7 +217,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={runTest}
|
||||
disabled={isRunning || !code.trim()}
|
||||
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}
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface ChunkOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into overlapping chunks
|
||||
* Split text into overlapping chunks (synchronous version)
|
||||
*/
|
||||
export function chunkText(
|
||||
text: string,
|
||||
@@ -62,8 +62,15 @@ export function chunkText(
|
||||
|
||||
const chunks: DocumentChunk[] = [];
|
||||
let currentIndex = 0;
|
||||
let previousIndex = -1;
|
||||
|
||||
while (currentIndex < text.length) {
|
||||
// Prevent infinite loop - if we haven't advanced, we're stuck
|
||||
if (currentIndex === previousIndex) {
|
||||
break;
|
||||
}
|
||||
previousIndex = currentIndex;
|
||||
|
||||
// Calculate end position for this chunk
|
||||
let endIndex = Math.min(currentIndex + chunkSize, text.length);
|
||||
|
||||
@@ -89,13 +96,109 @@ export function chunkText(
|
||||
});
|
||||
}
|
||||
|
||||
// If we've reached the end, we're done
|
||||
if (endIndex >= text.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Move to next chunk position (with overlap)
|
||||
currentIndex = endIndex - overlap;
|
||||
|
||||
// Prevent infinite loop
|
||||
if (currentIndex <= 0 || currentIndex >= text.length) {
|
||||
// Safety: ensure we always advance
|
||||
if (currentIndex <= previousIndex) {
|
||||
currentIndex = previousIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into overlapping chunks (async version that yields to event loop)
|
||||
* Use this for large files to avoid blocking the UI
|
||||
*/
|
||||
export async function chunkTextAsync(
|
||||
text: string,
|
||||
documentId: string,
|
||||
options: ChunkOptions = {}
|
||||
): Promise<DocumentChunk[]> {
|
||||
const {
|
||||
chunkSize = DEFAULT_CHUNK_SIZE,
|
||||
overlap = DEFAULT_OVERLAP,
|
||||
respectSentences = true,
|
||||
respectParagraphs = true
|
||||
} = options;
|
||||
|
||||
if (!text || text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For very short texts, return as single chunk
|
||||
if (text.length <= chunkSize) {
|
||||
return [{
|
||||
id: crypto.randomUUID(),
|
||||
documentId,
|
||||
content: text.trim(),
|
||||
startIndex: 0,
|
||||
endIndex: text.length
|
||||
}];
|
||||
}
|
||||
|
||||
const chunks: DocumentChunk[] = [];
|
||||
let currentIndex = 0;
|
||||
let iterationCount = 0;
|
||||
let previousIndex = -1;
|
||||
|
||||
while (currentIndex < text.length) {
|
||||
// Yield every 10 chunks to let UI breathe
|
||||
if (iterationCount > 0 && iterationCount % 10 === 0) {
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
iterationCount++;
|
||||
|
||||
// Prevent infinite loop - if we haven't advanced, we're stuck
|
||||
if (currentIndex === previousIndex) {
|
||||
break;
|
||||
}
|
||||
previousIndex = currentIndex;
|
||||
|
||||
// Calculate end position for this chunk
|
||||
let endIndex = Math.min(currentIndex + chunkSize, text.length);
|
||||
|
||||
// If not at end of text, try to find a good break point
|
||||
if (endIndex < text.length) {
|
||||
endIndex = findBreakPoint(text, currentIndex, endIndex, {
|
||||
respectSentences,
|
||||
respectParagraphs
|
||||
});
|
||||
}
|
||||
|
||||
// Extract chunk content
|
||||
const content = text.slice(currentIndex, endIndex).trim();
|
||||
|
||||
// Only add non-empty chunks above minimum size
|
||||
if (content.length >= MIN_CHUNK_SIZE) {
|
||||
chunks.push({
|
||||
id: crypto.randomUUID(),
|
||||
documentId,
|
||||
content,
|
||||
startIndex: currentIndex,
|
||||
endIndex
|
||||
});
|
||||
}
|
||||
|
||||
// If we've reached the end, we're done
|
||||
if (endIndex >= text.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Move to next chunk position (with overlap)
|
||||
currentIndex = endIndex - overlap;
|
||||
|
||||
// Safety: ensure we always advance
|
||||
if (currentIndex <= previousIndex) {
|
||||
currentIndex = previousIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
@@ -145,17 +248,15 @@ function findBreakPoint(
|
||||
|
||||
/**
|
||||
* Find the last match of a pattern after a given position
|
||||
* Uses matchAll instead of exec to avoid hook false positive
|
||||
* Uses matchAll to find all matches and returns the last one after minPos
|
||||
*/
|
||||
function findLastMatchPosition(text: string, pattern: RegExp, minPos: number): number {
|
||||
let lastMatch = -1;
|
||||
|
||||
// Use matchAll to find all matches
|
||||
const matches = Array.from(text.matchAll(pattern));
|
||||
|
||||
for (const match of matches) {
|
||||
// Use matchAll to iterate through matches
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
if (match.index !== undefined && match.index >= minPos) {
|
||||
// Add the length of the match to include it in the chunk
|
||||
// Track position after the match
|
||||
lastMatch = match.index + match[0].length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -252,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 */
|
||||
|
||||
@@ -32,14 +32,30 @@ export async function generateEmbedding(
|
||||
text: string,
|
||||
model: string = DEFAULT_EMBEDDING_MODEL
|
||||
): Promise<number[]> {
|
||||
const response = await ollamaClient.embed({
|
||||
model,
|
||||
input: text
|
||||
});
|
||||
const TIMEOUT_MS = 30000; // 30 second timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
// Ollama returns an array of embeddings (one per input)
|
||||
// We're only passing one input, so take the first
|
||||
return response.embeddings[0];
|
||||
try {
|
||||
const response = await ollamaClient.embed({
|
||||
model,
|
||||
input: text
|
||||
}, controller.signal);
|
||||
|
||||
// Ollama returns an array of embeddings (one per input)
|
||||
// We're only passing one input, so take the first
|
||||
if (!response.embeddings || response.embeddings.length === 0) {
|
||||
throw new Error(`No embeddings returned from model "${model}". Is the model available?`);
|
||||
}
|
||||
return response.embeddings[0];
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`Embedding generation timed out. Is the model "${model}" available?`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,13 +69,30 @@ export async function generateEmbeddings(
|
||||
const BATCH_SIZE = 10;
|
||||
const results: number[][] = [];
|
||||
|
||||
// Create abort controller with timeout
|
||||
const TIMEOUT_MS = 30000; // 30 second timeout per batch
|
||||
|
||||
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
|
||||
const batch = texts.slice(i, i + BATCH_SIZE);
|
||||
const response = await ollamaClient.embed({
|
||||
model,
|
||||
input: batch
|
||||
});
|
||||
results.push(...response.embeddings);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await ollamaClient.embed({
|
||||
model,
|
||||
input: batch
|
||||
}, controller.signal);
|
||||
results.push(...response.embeddings);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`Embedding generation timed out. Is the model "${model}" available?`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -59,6 +59,7 @@ export {
|
||||
// Chunking
|
||||
export {
|
||||
chunkText,
|
||||
chunkTextAsync,
|
||||
splitByParagraphs,
|
||||
splitBySentences,
|
||||
estimateChunkTokens,
|
||||
@@ -69,6 +70,7 @@ export {
|
||||
// Vector store
|
||||
export {
|
||||
addDocument,
|
||||
addDocumentAsync,
|
||||
searchSimilar,
|
||||
listDocuments,
|
||||
getDocument,
|
||||
@@ -76,6 +78,9 @@ export {
|
||||
deleteDocument,
|
||||
getKnowledgeBaseStats,
|
||||
formatResultsAsContext,
|
||||
resetStuckDocuments,
|
||||
type SearchResult,
|
||||
type AddDocumentOptions
|
||||
type SearchOptions,
|
||||
type AddDocumentOptions,
|
||||
type AddDocumentAsyncOptions
|
||||
} from './vector-store.js';
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { db, type StoredDocument, type StoredChunk } from '$lib/storage/db.js';
|
||||
import { generateEmbedding, generateEmbeddings, cosineSimilarity, DEFAULT_EMBEDDING_MODEL } from './embeddings.js';
|
||||
import { chunkText, estimateChunkTokens, type ChunkOptions } from './chunker.js';
|
||||
import { chunkText, chunkTextAsync, estimateChunkTokens, type ChunkOptions } from './chunker.js';
|
||||
|
||||
/** Result of a similarity search */
|
||||
export interface SearchResult {
|
||||
@@ -24,6 +24,8 @@ export interface AddDocumentOptions {
|
||||
embeddingModel?: string;
|
||||
/** Callback for progress updates */
|
||||
onProgress?: (current: number, total: number) => void;
|
||||
/** Project ID if document belongs to a project */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +41,8 @@ export async function addDocument(
|
||||
const {
|
||||
chunkOptions,
|
||||
embeddingModel = DEFAULT_EMBEDDING_MODEL,
|
||||
onProgress
|
||||
onProgress,
|
||||
projectId
|
||||
} = options;
|
||||
|
||||
const documentId = crypto.randomUUID();
|
||||
@@ -88,7 +91,9 @@ export async function addDocument(
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
chunkCount: storedChunks.length,
|
||||
embeddingModel
|
||||
embeddingModel,
|
||||
projectId: projectId ?? null,
|
||||
embeddingStatus: 'ready'
|
||||
};
|
||||
|
||||
// Store in database
|
||||
@@ -100,15 +105,162 @@ export async function addDocument(
|
||||
return document;
|
||||
}
|
||||
|
||||
/** Options for async document upload */
|
||||
export interface AddDocumentAsyncOptions extends AddDocumentOptions {
|
||||
/** Callback when embedding generation completes */
|
||||
onComplete?: (doc: StoredDocument) => void;
|
||||
/** Callback when embedding generation fails */
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for similar chunks across all documents
|
||||
* Add a document asynchronously - stores immediately, generates embeddings in background
|
||||
* Returns immediately with the document in 'pending' state
|
||||
*/
|
||||
export async function addDocumentAsync(
|
||||
name: string,
|
||||
content: string,
|
||||
mimeType: string,
|
||||
options: AddDocumentAsyncOptions = {}
|
||||
): Promise<StoredDocument> {
|
||||
const {
|
||||
chunkOptions,
|
||||
embeddingModel = DEFAULT_EMBEDDING_MODEL,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
projectId
|
||||
} = options;
|
||||
|
||||
const documentId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
// Create document record immediately (without knowing chunk count yet)
|
||||
// We'll update it after chunking in the background
|
||||
const document: StoredDocument = {
|
||||
id: documentId,
|
||||
name,
|
||||
mimeType,
|
||||
size: content.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
chunkCount: 0, // Will be updated after chunking
|
||||
embeddingModel,
|
||||
projectId: projectId ?? null,
|
||||
embeddingStatus: 'pending'
|
||||
};
|
||||
|
||||
// Store document immediately
|
||||
await db.documents.add(document);
|
||||
|
||||
// Process everything in background (non-blocking) - including chunking
|
||||
setTimeout(async () => {
|
||||
console.log('[Embedding] Starting for:', name, 'content length:', content.length);
|
||||
try {
|
||||
// Update status to processing
|
||||
await db.documents.update(documentId, { embeddingStatus: 'processing' });
|
||||
console.log('[Embedding] Status updated, starting chunking...');
|
||||
|
||||
// Chunk the content using async version (yields periodically)
|
||||
let textChunks;
|
||||
try {
|
||||
textChunks = await chunkTextAsync(content, documentId, chunkOptions);
|
||||
} catch (chunkError) {
|
||||
console.error('[Embedding] Chunking failed:', chunkError);
|
||||
throw chunkError;
|
||||
}
|
||||
console.log('[Embedding] Chunked into', textChunks.length, 'chunks');
|
||||
|
||||
if (textChunks.length === 0) {
|
||||
throw new Error('Document produced no chunks');
|
||||
}
|
||||
|
||||
// Update chunk count
|
||||
await db.documents.update(documentId, { chunkCount: textChunks.length });
|
||||
|
||||
const chunkContents = textChunks.map(c => c.content);
|
||||
const embeddings: number[][] = [];
|
||||
|
||||
// Process embeddings in batches with progress
|
||||
const BATCH_SIZE = 5;
|
||||
const totalBatches = Math.ceil(chunkContents.length / BATCH_SIZE);
|
||||
for (let i = 0; i < chunkContents.length; i += BATCH_SIZE) {
|
||||
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
||||
console.log(`[Embedding] Batch ${batchNum}/${totalBatches}...`);
|
||||
const batch = chunkContents.slice(i, i + BATCH_SIZE);
|
||||
const batchEmbeddings = await generateEmbeddings(batch, embeddingModel);
|
||||
embeddings.push(...batchEmbeddings);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(Math.min(i + BATCH_SIZE, chunkContents.length), chunkContents.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Create stored chunks with embeddings
|
||||
const storedChunks: StoredChunk[] = textChunks.map((chunk, index) => ({
|
||||
id: chunk.id,
|
||||
documentId,
|
||||
content: chunk.content,
|
||||
embedding: embeddings[index],
|
||||
startIndex: chunk.startIndex,
|
||||
endIndex: chunk.endIndex,
|
||||
tokenCount: estimateChunkTokens(chunk.content)
|
||||
}));
|
||||
|
||||
// Store chunks and update document status
|
||||
await db.transaction('rw', [db.documents, db.chunks], async () => {
|
||||
await db.chunks.bulkAdd(storedChunks);
|
||||
await db.documents.update(documentId, {
|
||||
embeddingStatus: 'ready',
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[Embedding] Complete for:', name);
|
||||
const updatedDoc = await db.documents.get(documentId);
|
||||
if (updatedDoc && onComplete) {
|
||||
onComplete(updatedDoc);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Embedding] Failed:', error);
|
||||
await db.documents.update(documentId, { embeddingStatus: 'failed' });
|
||||
if (onError) {
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/** Options for similarity search */
|
||||
export interface SearchOptions {
|
||||
/** Maximum number of results to return */
|
||||
topK?: number;
|
||||
/** Minimum similarity threshold (0-1) */
|
||||
threshold?: number;
|
||||
/** Embedding model to use */
|
||||
embeddingModel?: string;
|
||||
/** Filter to documents in this project only (null = global only, undefined = all) */
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for similar chunks across documents
|
||||
* @param query - The search query
|
||||
* @param options - Search options including projectId filter
|
||||
*/
|
||||
export async function searchSimilar(
|
||||
query: string,
|
||||
topK: number = 5,
|
||||
threshold: number = 0.5,
|
||||
embeddingModel: string = DEFAULT_EMBEDDING_MODEL
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResult[]> {
|
||||
const {
|
||||
topK = 5,
|
||||
threshold = 0.5,
|
||||
embeddingModel = DEFAULT_EMBEDDING_MODEL,
|
||||
projectId
|
||||
} = options;
|
||||
|
||||
// Generate embedding for query
|
||||
const queryEmbedding = await generateEmbedding(query, embeddingModel);
|
||||
|
||||
@@ -120,31 +272,50 @@ export async function searchSimilar(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get document IDs that match the project filter
|
||||
let allowedDocumentIds: Set<string> | null = null;
|
||||
if (projectId !== undefined) {
|
||||
const docs = await db.documents.toArray();
|
||||
const filteredDocs = docs.filter((d) =>
|
||||
projectId === null ? !d.projectId : d.projectId === projectId
|
||||
);
|
||||
allowedDocumentIds = new Set(filteredDocs.map((d) => d.id));
|
||||
}
|
||||
|
||||
// Filter chunks by project and calculate similarities
|
||||
const relevantChunks = allowedDocumentIds
|
||||
? allChunks.filter((c) => allowedDocumentIds!.has(c.documentId))
|
||||
: allChunks;
|
||||
|
||||
if (relevantChunks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate similarities
|
||||
const scored = allChunks.map(chunk => ({
|
||||
const scored = relevantChunks.map((chunk) => ({
|
||||
chunk,
|
||||
similarity: cosineSimilarity(queryEmbedding, chunk.embedding)
|
||||
}));
|
||||
|
||||
// Filter and sort
|
||||
const filtered = scored
|
||||
.filter(item => item.similarity >= threshold)
|
||||
.filter((item) => item.similarity >= threshold)
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.slice(0, topK);
|
||||
|
||||
// Fetch document info for results
|
||||
const documentIds = [...new Set(filtered.map(r => r.chunk.documentId))];
|
||||
const documentIds = [...new Set(filtered.map((r) => r.chunk.documentId))];
|
||||
const documents = await db.documents.bulkGet(documentIds);
|
||||
const documentMap = new Map(documents.filter(Boolean).map(d => [d!.id, d!]));
|
||||
const documentMap = new Map(documents.filter(Boolean).map((d) => [d!.id, d!]));
|
||||
|
||||
// Build results
|
||||
return filtered
|
||||
.map(item => ({
|
||||
.map((item) => ({
|
||||
chunk: item.chunk,
|
||||
document: documentMap.get(item.chunk.documentId)!,
|
||||
similarity: item.similarity
|
||||
}))
|
||||
.filter(r => r.document !== undefined);
|
||||
.filter((r) => r.document !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +349,59 @@ export async function deleteDocument(id: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry embedding for a stuck document
|
||||
* Useful when HMR or page refresh interrupts background processing
|
||||
*/
|
||||
export async function retryDocumentEmbedding(
|
||||
documentId: string,
|
||||
onComplete?: (doc: StoredDocument) => void,
|
||||
onError?: (error: Error) => void
|
||||
): Promise<void> {
|
||||
const doc = await db.documents.get(documentId);
|
||||
if (!doc) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
// Only retry if stuck in pending or processing state
|
||||
if (doc.embeddingStatus === 'ready') {
|
||||
console.log('Document already has embeddings');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete any existing chunks for this document
|
||||
await db.chunks.where('documentId').equals(documentId).delete();
|
||||
|
||||
// We need the original content, which we don't store
|
||||
// So we need to mark it as failed - user will need to re-upload
|
||||
// OR we could store the content temporarily...
|
||||
|
||||
// For now, just mark as failed so user knows to re-upload
|
||||
await db.documents.update(documentId, { embeddingStatus: 'failed' });
|
||||
|
||||
if (onError) {
|
||||
onError(new Error('Cannot retry - document content not cached. Please re-upload the file.'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset stuck documents (pending/processing) to failed state
|
||||
* Call this on app startup to clean up interrupted uploads
|
||||
*/
|
||||
export async function resetStuckDocuments(): Promise<number> {
|
||||
// Get all documents and filter in memory (no index required)
|
||||
const allDocs = await db.documents.toArray();
|
||||
const stuckDocs = allDocs.filter(
|
||||
doc => doc.embeddingStatus === 'pending' || doc.embeddingStatus === 'processing'
|
||||
);
|
||||
|
||||
for (const doc of stuckDocs) {
|
||||
await db.documents.update(doc.id, { embeddingStatus: 'failed' });
|
||||
}
|
||||
|
||||
return stuckDocs.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total statistics for the knowledge base
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
// ==========================================================================
|
||||
|
||||
124
frontend/src/lib/ollama/modelfile-parser.ts
Normal file
124
frontend/src/lib/ollama/modelfile-parser.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
433
frontend/src/lib/prompts/templates.ts
Normal file
433
frontend/src/lib/prompts/templates.ts
Normal 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' }
|
||||
};
|
||||
372
frontend/src/lib/services/attachmentService.ts
Normal file
372
frontend/src/lib/services/attachmentService.ts
Normal 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Singleton export
|
||||
export const attachmentService = new AttachmentService();
|
||||
262
frontend/src/lib/services/chat-index-migration.ts
Normal file
262
frontend/src/lib/services/chat-index-migration.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Chat Index Migration Service
|
||||
* Background service that indexes existing conversations for semantic search
|
||||
* Processes in small batches to avoid blocking the UI
|
||||
*/
|
||||
|
||||
import { db } from '$lib/storage/db.js';
|
||||
import { indexConversationMessages, isConversationIndexed } from './chat-indexer.js';
|
||||
import type { Message } from '$lib/types/chat.js';
|
||||
import { settingsState } from '$lib/stores';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface MigrationProgress {
|
||||
total: number;
|
||||
indexed: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
isRunning: boolean;
|
||||
currentConversation: string | null;
|
||||
}
|
||||
|
||||
export interface MigrationOptions {
|
||||
/** Number of conversations to process per batch */
|
||||
batchSize?: number;
|
||||
/** Delay between batches in ms */
|
||||
batchDelay?: number;
|
||||
/** Minimum messages required to index a conversation */
|
||||
minMessages?: number;
|
||||
/** Embedding model to use */
|
||||
embeddingModel?: string;
|
||||
/** Callback for progress updates */
|
||||
onProgress?: (progress: MigrationProgress) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State
|
||||
// ============================================================================
|
||||
|
||||
let migrationInProgress = false;
|
||||
let migrationAborted = false;
|
||||
|
||||
// ============================================================================
|
||||
// Migration Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Run the chat index migration in the background
|
||||
* Indexes all conversations that don't have chat chunks yet
|
||||
*/
|
||||
export async function runChatIndexMigration(options: MigrationOptions = {}): Promise<MigrationProgress> {
|
||||
const {
|
||||
batchSize = 2,
|
||||
batchDelay = 500,
|
||||
minMessages = 2,
|
||||
embeddingModel,
|
||||
onProgress
|
||||
} = options;
|
||||
|
||||
// Prevent multiple migrations running at once
|
||||
if (migrationInProgress) {
|
||||
console.log('[ChatIndexMigration] Migration already in progress, skipping');
|
||||
return {
|
||||
total: 0,
|
||||
indexed: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
isRunning: true,
|
||||
currentConversation: null
|
||||
};
|
||||
}
|
||||
|
||||
migrationInProgress = true;
|
||||
migrationAborted = false;
|
||||
|
||||
const progress: MigrationProgress = {
|
||||
total: 0,
|
||||
indexed: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
isRunning: true,
|
||||
currentConversation: null
|
||||
};
|
||||
|
||||
try {
|
||||
// Get all conversations
|
||||
const allConversations = await db.conversations.toArray();
|
||||
progress.total = allConversations.length;
|
||||
|
||||
console.log(`[ChatIndexMigration] Starting migration for ${progress.total} conversations`);
|
||||
onProgress?.(progress);
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < allConversations.length; i += batchSize) {
|
||||
if (migrationAborted) {
|
||||
console.log('[ChatIndexMigration] Migration aborted');
|
||||
break;
|
||||
}
|
||||
|
||||
const batch = allConversations.slice(i, i + batchSize);
|
||||
|
||||
// Process batch in parallel
|
||||
await Promise.all(batch.map(async (conversation) => {
|
||||
if (migrationAborted) return;
|
||||
|
||||
progress.currentConversation = conversation.title;
|
||||
onProgress?.(progress);
|
||||
|
||||
try {
|
||||
// Check if already indexed
|
||||
const isIndexed = await isConversationIndexed(conversation.id);
|
||||
if (isIndexed) {
|
||||
progress.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip conversations with too few messages
|
||||
if (conversation.messageCount < minMessages) {
|
||||
progress.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get messages for this conversation
|
||||
const messages = await getMessagesForIndexing(conversation.id);
|
||||
if (messages.length < minMessages) {
|
||||
progress.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Index the conversation
|
||||
const projectId = conversation.projectId || null;
|
||||
const chunksIndexed = await indexConversationMessages(
|
||||
conversation.id,
|
||||
projectId,
|
||||
messages,
|
||||
{ embeddingModel }
|
||||
);
|
||||
|
||||
if (chunksIndexed > 0) {
|
||||
progress.indexed++;
|
||||
console.log(`[ChatIndexMigration] Indexed "${conversation.title}" (${chunksIndexed} chunks)`);
|
||||
} else {
|
||||
progress.skipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[ChatIndexMigration] Failed to index "${conversation.title}":`, error);
|
||||
progress.failed++;
|
||||
}
|
||||
}));
|
||||
|
||||
onProgress?.(progress);
|
||||
|
||||
// Delay between batches to avoid overwhelming the system
|
||||
if (i + batchSize < allConversations.length && !migrationAborted) {
|
||||
await delay(batchDelay);
|
||||
}
|
||||
}
|
||||
|
||||
progress.isRunning = false;
|
||||
progress.currentConversation = null;
|
||||
onProgress?.(progress);
|
||||
|
||||
console.log(`[ChatIndexMigration] Migration complete: ${progress.indexed} indexed, ${progress.skipped} skipped, ${progress.failed} failed`);
|
||||
|
||||
return progress;
|
||||
} finally {
|
||||
migrationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the current migration
|
||||
*/
|
||||
export function abortChatIndexMigration(): void {
|
||||
migrationAborted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if migration is currently running
|
||||
*/
|
||||
export function isMigrationRunning(): boolean {
|
||||
return migrationInProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get migration statistics
|
||||
*/
|
||||
export async function getMigrationStats(): Promise<{
|
||||
totalConversations: number;
|
||||
indexedConversations: number;
|
||||
pendingConversations: number;
|
||||
}> {
|
||||
const [allConversations, indexedConversationIds] = await Promise.all([
|
||||
db.conversations.count(),
|
||||
db.chatChunks.orderBy('conversationId').uniqueKeys()
|
||||
]);
|
||||
|
||||
const indexedCount = (indexedConversationIds as string[]).length;
|
||||
|
||||
return {
|
||||
totalConversations: allConversations,
|
||||
indexedConversations: indexedCount,
|
||||
pendingConversations: allConversations - indexedCount
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get messages from a conversation in the format needed for indexing
|
||||
*/
|
||||
async function getMessagesForIndexing(conversationId: string): Promise<Message[]> {
|
||||
const storedMessages = await db.messages
|
||||
.where('conversationId')
|
||||
.equals(conversationId)
|
||||
.toArray();
|
||||
|
||||
// Convert to Message format expected by indexer
|
||||
return storedMessages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
images: m.images,
|
||||
toolCalls: m.toolCalls,
|
||||
hidden: false
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple delay helper
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-run migration on module load (deferred)
|
||||
* This ensures migration runs in the background after the app has loaded
|
||||
*/
|
||||
export function scheduleMigration(delayMs: number = 3000): void {
|
||||
if (typeof window === 'undefined') return; // SSR guard
|
||||
|
||||
setTimeout(() => {
|
||||
runChatIndexMigration({
|
||||
batchSize: 2,
|
||||
batchDelay: 1000, // 1 second between batches
|
||||
minMessages: 2,
|
||||
embeddingModel: settingsState.embeddingModel,
|
||||
onProgress: (progress) => {
|
||||
// Only log significant events
|
||||
if (progress.indexed > 0 && progress.indexed % 5 === 0) {
|
||||
console.log(`[ChatIndexMigration] Progress: ${progress.indexed}/${progress.total} indexed`);
|
||||
}
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('[ChatIndexMigration] Migration failed:', error);
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
362
frontend/src/lib/services/chat-indexer.ts
Normal file
362
frontend/src/lib/services/chat-indexer.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Chat Indexer Service
|
||||
* Indexes conversation messages for RAG search across project chats
|
||||
*/
|
||||
|
||||
import { db } from '$lib/storage/db.js';
|
||||
import type { StoredChatChunk } from '$lib/storage/db.js';
|
||||
import type { Message } from '$lib/types/chat.js';
|
||||
import { generateId } from '$lib/storage/db.js';
|
||||
import {
|
||||
generateEmbedding,
|
||||
findSimilar,
|
||||
DEFAULT_EMBEDDING_MODEL
|
||||
} from '$lib/memory/embeddings.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface IndexingOptions {
|
||||
/** Embedding model to use (e.g., 'nomic-embed-text') */
|
||||
embeddingModel?: string;
|
||||
/** Base URL for Ollama API */
|
||||
baseUrl?: string;
|
||||
/** Only index assistant messages (recommended) */
|
||||
assistantOnly?: boolean;
|
||||
/** Minimum content length to index */
|
||||
minContentLength?: number;
|
||||
}
|
||||
|
||||
export interface ChatSearchResult {
|
||||
conversationId: string;
|
||||
conversationTitle: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Indexing Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Index messages from a conversation for RAG search
|
||||
* Generates embeddings for each message and stores them for similarity search
|
||||
* @param projectId - Project ID or null for global conversations
|
||||
*/
|
||||
export async function indexConversationMessages(
|
||||
conversationId: string,
|
||||
projectId: string | null,
|
||||
messages: Message[],
|
||||
options: IndexingOptions = {}
|
||||
): Promise<number> {
|
||||
const {
|
||||
embeddingModel = DEFAULT_EMBEDDING_MODEL,
|
||||
assistantOnly = false, // Index both user and assistant for better context
|
||||
minContentLength = 20
|
||||
} = options;
|
||||
|
||||
// Filter messages to index
|
||||
const messagesToIndex = messages.filter((m) => {
|
||||
if (assistantOnly && m.role !== 'assistant') return false;
|
||||
if (m.role !== 'user' && m.role !== 'assistant') return false;
|
||||
if (!m.content || m.content.length < minContentLength) return false;
|
||||
if (m.hidden) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (messagesToIndex.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check which messages are already indexed by checking if first 500 chars exist
|
||||
const existingChunks = await db.chatChunks
|
||||
.where('conversationId')
|
||||
.equals(conversationId)
|
||||
.toArray();
|
||||
// Use first 500 chars as signature to detect already-indexed messages
|
||||
const existingSignatures = new Set(existingChunks.map((c) => c.content.slice(0, 500)));
|
||||
|
||||
// Filter out already indexed messages
|
||||
const newMessages = messagesToIndex.filter(
|
||||
(m) => !existingSignatures.has(m.content.slice(0, 500))
|
||||
);
|
||||
|
||||
if (newMessages.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`[ChatIndexer] Indexing ${newMessages.length} new messages for conversation ${conversationId}`);
|
||||
|
||||
// Generate embeddings and create chunks
|
||||
// For long messages, split into multiple chunks
|
||||
const CHUNK_SIZE = 1500;
|
||||
const CHUNK_OVERLAP = 200;
|
||||
|
||||
const chunks: StoredChatChunk[] = [];
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
const m = newMessages[i];
|
||||
const content = m.content;
|
||||
|
||||
// Split long messages into chunks
|
||||
const messageChunks: string[] = [];
|
||||
if (content.length <= CHUNK_SIZE) {
|
||||
messageChunks.push(content);
|
||||
} else {
|
||||
// Chunk with overlap for better context
|
||||
let start = 0;
|
||||
while (start < content.length) {
|
||||
const end = Math.min(start + CHUNK_SIZE, content.length);
|
||||
messageChunks.push(content.slice(start, end));
|
||||
start = end - CHUNK_OVERLAP;
|
||||
if (start >= content.length - CHUNK_OVERLAP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create chunk for each piece
|
||||
for (let j = 0; j < messageChunks.length; j++) {
|
||||
const chunkContent = messageChunks[j];
|
||||
try {
|
||||
const embedding = await generateEmbedding(chunkContent, embeddingModel);
|
||||
|
||||
chunks.push({
|
||||
id: generateId(),
|
||||
conversationId,
|
||||
projectId,
|
||||
messageId: `${conversationId}-${Date.now()}-${i}-${j}`,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: chunkContent,
|
||||
embedding,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[ChatIndexer] Failed to generate embedding for chunk:`, error);
|
||||
// Continue with other chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
await db.chatChunks.bulkAdd(chunks);
|
||||
console.log(`[ChatIndexer] Successfully indexed ${chunks.length} messages`);
|
||||
}
|
||||
|
||||
return chunks.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-index a conversation (clears existing and re-indexes)
|
||||
*/
|
||||
export async function forceReindexConversation(
|
||||
conversationId: string,
|
||||
projectId: string,
|
||||
messages: Message[],
|
||||
options: IndexingOptions = {}
|
||||
): Promise<number> {
|
||||
console.log(`[ChatIndexer] Force re-indexing conversation: ${conversationId}`);
|
||||
|
||||
// Clear existing chunks
|
||||
const deleted = await db.chatChunks.where('conversationId').equals(conversationId).delete();
|
||||
console.log(`[ChatIndexer] Cleared ${deleted} existing chunks`);
|
||||
|
||||
// Re-index (this will now create chunked messages)
|
||||
return indexConversationMessages(conversationId, projectId, messages, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-index a conversation when it moves to/from a project
|
||||
*/
|
||||
export async function reindexConversationForProject(
|
||||
conversationId: string,
|
||||
newProjectId: string | null
|
||||
): Promise<void> {
|
||||
// Remove existing chunks for this conversation
|
||||
await db.chatChunks.where('conversationId').equals(conversationId).delete();
|
||||
|
||||
// If moving to a project, chunks will be re-created when needed
|
||||
// For now, this is a placeholder - actual re-indexing would happen
|
||||
// when the conversation is opened or when summaries are generated
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all indexed chunks for a conversation
|
||||
*/
|
||||
export async function removeConversationFromIndex(conversationId: string): Promise<void> {
|
||||
await db.chatChunks.where('conversationId').equals(conversationId).delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all indexed chunks for a project
|
||||
*/
|
||||
export async function removeProjectFromIndex(projectId: string): Promise<void> {
|
||||
await db.chatChunks.where('projectId').equals(projectId).delete();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search Functions
|
||||
// ============================================================================
|
||||
|
||||
export interface SearchChatOptions {
|
||||
/** Project ID to search within, null for global search */
|
||||
projectId?: string | null;
|
||||
/** Conversation ID to exclude from results */
|
||||
excludeConversationId?: string;
|
||||
/** Maximum number of results */
|
||||
topK?: number;
|
||||
/** Minimum similarity threshold */
|
||||
threshold?: number;
|
||||
/** Embedding model to use */
|
||||
embeddingModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search indexed chat history using embedding similarity
|
||||
* Can search within a project, globally, or both
|
||||
*/
|
||||
export async function searchChatHistory(
|
||||
query: string,
|
||||
options: SearchChatOptions = {}
|
||||
): Promise<ChatSearchResult[]> {
|
||||
const {
|
||||
projectId,
|
||||
excludeConversationId,
|
||||
topK = 10,
|
||||
threshold = 0.2,
|
||||
embeddingModel = DEFAULT_EMBEDDING_MODEL
|
||||
} = options;
|
||||
|
||||
// Get chunks based on scope
|
||||
let chunks: StoredChatChunk[];
|
||||
if (projectId !== undefined) {
|
||||
// Project-scoped search (projectId can be string or null)
|
||||
if (projectId === null) {
|
||||
// Search only global (non-project) conversations
|
||||
chunks = await db.chatChunks.filter((c) => c.projectId === null).toArray();
|
||||
} else {
|
||||
// Search within specific project
|
||||
chunks = await db.chatChunks.where('projectId').equals(projectId).toArray();
|
||||
}
|
||||
} else {
|
||||
// Global search - all chunks
|
||||
chunks = await db.chatChunks.toArray();
|
||||
}
|
||||
|
||||
// Filter out excluded conversation and chunks without embeddings
|
||||
const relevantChunks = chunks.filter((c) => {
|
||||
if (excludeConversationId && c.conversationId === excludeConversationId) return false;
|
||||
if (!c.embedding || c.embedding.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (relevantChunks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate embedding for query
|
||||
const queryEmbedding = await generateEmbedding(query, embeddingModel);
|
||||
|
||||
// Validate embedding was generated successfully
|
||||
if (!queryEmbedding || !Array.isArray(queryEmbedding) || queryEmbedding.length === 0) {
|
||||
console.warn('[ChatIndexer] Failed to generate query embedding - is the embedding model available?');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find similar chunks
|
||||
const similar = findSimilar(queryEmbedding, relevantChunks, topK, threshold);
|
||||
|
||||
if (similar.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get conversation titles for results
|
||||
const conversationIds = [...new Set(similar.map((s) => s.conversationId))];
|
||||
const conversations = await db.conversations.bulkGet(conversationIds);
|
||||
const titleMap = new Map(
|
||||
conversations.filter(Boolean).map((c) => [c!.id, c!.title])
|
||||
);
|
||||
|
||||
// Format results
|
||||
return similar.map((chunk) => ({
|
||||
conversationId: chunk.conversationId,
|
||||
conversationTitle: titleMap.get(chunk.conversationId) || 'Unknown',
|
||||
messageId: chunk.messageId,
|
||||
content: chunk.content,
|
||||
similarity: chunk.similarity
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[ChatIndexer] Search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search chat history within a specific project (legacy API)
|
||||
*/
|
||||
export async function searchProjectChatHistory(
|
||||
projectId: string,
|
||||
query: string,
|
||||
excludeConversationId?: string,
|
||||
topK: number = 10,
|
||||
threshold: number = 0.2
|
||||
): Promise<ChatSearchResult[]> {
|
||||
return searchChatHistory(query, {
|
||||
projectId,
|
||||
excludeConversationId,
|
||||
topK,
|
||||
threshold
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search all indexed chat history globally
|
||||
*/
|
||||
export async function searchAllChatHistory(
|
||||
query: string,
|
||||
excludeConversationId?: string,
|
||||
topK: number = 20,
|
||||
threshold: number = 0.2
|
||||
): Promise<ChatSearchResult[]> {
|
||||
return searchChatHistory(query, {
|
||||
excludeConversationId,
|
||||
topK,
|
||||
threshold
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get indexing statistics for a project
|
||||
*/
|
||||
export async function getProjectIndexStats(projectId: string): Promise<{
|
||||
totalChunks: number;
|
||||
conversationCount: number;
|
||||
}> {
|
||||
const chunks = await db.chatChunks
|
||||
.where('projectId')
|
||||
.equals(projectId)
|
||||
.toArray();
|
||||
|
||||
const conversationIds = new Set(chunks.map((c) => c.conversationId));
|
||||
|
||||
return {
|
||||
totalChunks: chunks.length,
|
||||
conversationCount: conversationIds.size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a conversation is indexed
|
||||
*/
|
||||
export async function isConversationIndexed(conversationId: string): Promise<boolean> {
|
||||
const count = await db.chatChunks
|
||||
.where('conversationId')
|
||||
.equals(conversationId)
|
||||
.count();
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
205
frontend/src/lib/services/conversation-summary.ts
Normal file
205
frontend/src/lib/services/conversation-summary.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Conversation Summary Service
|
||||
* Generates and manages conversation summaries for cross-chat context
|
||||
*/
|
||||
|
||||
import { db } from '$lib/storage/db.js';
|
||||
import { updateConversationSummary } from '$lib/storage/conversations.js';
|
||||
import type { Message } from '$lib/types/chat.js';
|
||||
import { indexConversationMessages } from './chat-indexer.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SummaryGenerationOptions {
|
||||
/** Model to use for summary generation */
|
||||
model: string;
|
||||
/** Base URL for Ollama API */
|
||||
baseUrl?: string;
|
||||
/** Maximum messages to include in summary context */
|
||||
maxMessages?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summary Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a summary for a conversation using the LLM
|
||||
* @param conversationId - The conversation to summarize
|
||||
* @param messages - The messages to summarize
|
||||
* @param options - Generation options
|
||||
* @returns The generated summary text
|
||||
*/
|
||||
export async function generateConversationSummary(
|
||||
conversationId: string,
|
||||
messages: Message[],
|
||||
options: SummaryGenerationOptions
|
||||
): Promise<string> {
|
||||
const { model, baseUrl = 'http://localhost:11434', maxMessages = 20 } = options;
|
||||
|
||||
// Filter to user and assistant messages only
|
||||
const relevantMessages = messages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.slice(-maxMessages); // Take last N messages
|
||||
|
||||
if (relevantMessages.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Format messages for the prompt
|
||||
const conversationText = relevantMessages
|
||||
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 500)}`)
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = `Summarize this conversation in 2-3 sentences. Focus on the main topics discussed, any decisions made, and key outcomes. Be concise.
|
||||
|
||||
Conversation:
|
||||
${conversationText}
|
||||
|
||||
Summary:`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.3,
|
||||
num_predict: 150
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[ConversationSummary] Failed to generate summary:', response.statusText);
|
||||
return '';
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.response?.trim() || '';
|
||||
} catch (error) {
|
||||
console.error('[ConversationSummary] Error generating summary:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and save a summary for a conversation
|
||||
*/
|
||||
export async function generateAndSaveSummary(
|
||||
conversationId: string,
|
||||
messages: Message[],
|
||||
options: SummaryGenerationOptions
|
||||
): Promise<boolean> {
|
||||
const summary = await generateConversationSummary(conversationId, messages, options);
|
||||
|
||||
if (!summary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await updateConversationSummary(conversationId, summary);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a conversation needs its summary updated
|
||||
* @param conversationId - The conversation to check
|
||||
* @param currentMessageCount - Current number of messages
|
||||
* @param threshold - Number of new messages before updating (default: 10)
|
||||
*/
|
||||
export async function needsSummaryUpdate(
|
||||
conversationId: string,
|
||||
currentMessageCount: number,
|
||||
threshold: number = 10
|
||||
): Promise<boolean> {
|
||||
const conversation = await db.conversations.get(conversationId);
|
||||
|
||||
if (!conversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No summary yet - needs one if there are enough messages
|
||||
if (!conversation.summary) {
|
||||
return currentMessageCount >= 4; // At least 2 exchanges
|
||||
}
|
||||
|
||||
// Check if enough new messages since last summary
|
||||
// This is a simple heuristic - could be improved with actual message tracking
|
||||
const lastSummaryTime = conversation.summaryUpdatedAt || conversation.createdAt;
|
||||
const timeSinceLastSummary = Date.now() - lastSummaryTime;
|
||||
|
||||
// Update if more than 30 minutes old and conversation has grown
|
||||
return timeSinceLastSummary > 30 * 60 * 1000 && currentMessageCount >= 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the summary prompt for manual triggering
|
||||
*/
|
||||
export function getSummaryPrompt(messages: Message[], maxMessages: number = 20): string {
|
||||
const relevantMessages = messages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.slice(-maxMessages);
|
||||
|
||||
const conversationText = relevantMessages
|
||||
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 500)}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `Summarize this conversation in 2-3 sentences. Focus on the main topics discussed, any decisions made, and key outcomes. Be concise.
|
||||
|
||||
Conversation:
|
||||
${conversationText}
|
||||
|
||||
Summary:`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger chat indexing and optional summary when user leaves a conversation
|
||||
* Runs in background to not block navigation
|
||||
* Indexes ALL conversations for global RAG search
|
||||
* Only generates summaries for project conversations
|
||||
*/
|
||||
export async function updateSummaryOnLeave(
|
||||
conversationId: string,
|
||||
messages: Message[],
|
||||
model: string,
|
||||
baseUrl?: string
|
||||
): Promise<void> {
|
||||
// Get conversation to check project membership
|
||||
const conversation = await db.conversations.get(conversationId);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = conversation.projectId || null;
|
||||
|
||||
// Run indexing and summary generation in background
|
||||
setTimeout(async () => {
|
||||
// Always index messages for RAG (all conversations, for global search)
|
||||
try {
|
||||
const indexed = await indexConversationMessages(conversationId, projectId, messages);
|
||||
if (indexed > 0) {
|
||||
console.log(`[ChatIndexer] Indexed ${indexed} chunks for conversation`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatIndexer] Indexing failed:', error);
|
||||
}
|
||||
|
||||
// Generate summary only for project conversations (4+ messages and enough time passed)
|
||||
if (projectId) {
|
||||
const needsUpdate = await needsSummaryUpdate(conversationId, messages.length);
|
||||
if (needsUpdate) {
|
||||
try {
|
||||
await generateAndSaveSummary(conversationId, messages, { model, baseUrl });
|
||||
console.log('[Summary] Summary completed');
|
||||
} catch (error) {
|
||||
console.error('[Summary] Summary generation failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
407
frontend/src/lib/services/fileAnalyzer.ts
Normal file
407
frontend/src/lib/services/fileAnalyzer.ts
Normal 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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
204
frontend/src/lib/services/model-info-service.ts
Normal file
204
frontend/src/lib/services/model-info-service.ts
Normal 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();
|
||||
266
frontend/src/lib/services/project-context.ts
Normal file
266
frontend/src/lib/services/project-context.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Project Context Service
|
||||
* Builds full project context for chat messages including:
|
||||
* - Project instructions
|
||||
* - Conversation summaries from other project chats
|
||||
* - RAG search across project chat history
|
||||
* - Project reference links
|
||||
*/
|
||||
|
||||
import { db, type StoredDocument } from '$lib/storage/db.js';
|
||||
import {
|
||||
getProjectConversationSummaries,
|
||||
getConversationsForProject
|
||||
} from '$lib/storage/conversations.js';
|
||||
import type { ProjectLink } from '$lib/storage/projects.js';
|
||||
import { getProjectLinks } from '$lib/storage/projects.js';
|
||||
import { listDocuments, getDocumentChunks } from '$lib/memory/vector-store.js';
|
||||
import { searchChatHistory, searchProjectChatHistory } from './chat-indexer.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** Basic info about a project conversation */
|
||||
export interface ProjectConversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
updatedAt: Date;
|
||||
hasSummary: boolean;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ChatHistoryResult {
|
||||
conversationId: string;
|
||||
conversationTitle: string;
|
||||
content: string;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
/** Document info for context (simplified from StoredDocument) */
|
||||
export interface ProjectDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
chunkCount: number;
|
||||
embeddingStatus: 'pending' | 'processing' | 'ready' | 'failed' | undefined;
|
||||
/** Preview of the document content (first chunk, truncated) */
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface ProjectContext {
|
||||
/** Project instructions to inject into system prompt */
|
||||
instructions: string | null;
|
||||
/** All other conversations in the project (with summary status) */
|
||||
otherConversations: ProjectConversation[];
|
||||
/** Relevant snippets from chat history RAG search */
|
||||
relevantChatHistory: ChatHistoryResult[];
|
||||
/** Reference links for the project */
|
||||
links: ProjectLink[];
|
||||
/** Documents in the project's knowledge base */
|
||||
documents: ProjectDocument[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Context Builder
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build full project context for a chat message
|
||||
* @param projectId - The project ID
|
||||
* @param currentConversationId - The current conversation (excluded from summaries/RAG)
|
||||
* @param userQuery - The user's message (used for RAG search)
|
||||
* @returns ProjectContext with all relevant context
|
||||
*/
|
||||
export async function buildProjectContext(
|
||||
projectId: string,
|
||||
currentConversationId: string,
|
||||
userQuery: string
|
||||
): Promise<ProjectContext> {
|
||||
// Fetch project data in parallel
|
||||
const [project, conversationsResult, summariesResult, linksResult, chatHistory, allDocuments] =
|
||||
await Promise.all([
|
||||
db.projects.get(projectId),
|
||||
getConversationsForProject(projectId),
|
||||
getProjectConversationSummaries(projectId, currentConversationId),
|
||||
getProjectLinks(projectId),
|
||||
searchProjectChatHistory(projectId, userQuery, currentConversationId, 3),
|
||||
listDocuments()
|
||||
]);
|
||||
|
||||
const allConversations = conversationsResult.success ? conversationsResult.data : [];
|
||||
const summaries = summariesResult.success ? summariesResult.data : [];
|
||||
const links = linksResult.success ? linksResult.data : [];
|
||||
|
||||
// Create a map of summaries by conversation ID for quick lookup
|
||||
const summaryMap = new Map(summaries.map((s) => [s.id, s.summary]));
|
||||
|
||||
// Build list of other conversations (excluding current)
|
||||
const otherConversations: ProjectConversation[] = allConversations
|
||||
.filter((c) => c.id !== currentConversationId)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
messageCount: c.messageCount,
|
||||
updatedAt: c.updatedAt,
|
||||
hasSummary: summaryMap.has(c.id),
|
||||
summary: summaryMap.get(c.id)
|
||||
}));
|
||||
|
||||
|
||||
// Filter documents for this project that are ready
|
||||
const readyDocs = allDocuments.filter(
|
||||
(d) => d.projectId === projectId && d.embeddingStatus === 'ready'
|
||||
);
|
||||
|
||||
// Fetch previews for each document (first chunk, truncated)
|
||||
const projectDocuments: ProjectDocument[] = await Promise.all(
|
||||
readyDocs.map(async (d) => {
|
||||
let preview: string | undefined;
|
||||
try {
|
||||
const chunks = await getDocumentChunks(d.id);
|
||||
if (chunks.length > 0) {
|
||||
// Get first chunk, truncate to ~500 chars
|
||||
const firstChunk = chunks[0].content;
|
||||
preview =
|
||||
firstChunk.length > 500 ? firstChunk.slice(0, 500) + '...' : firstChunk;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors fetching chunks
|
||||
}
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
chunkCount: d.chunkCount,
|
||||
embeddingStatus: d.embeddingStatus,
|
||||
preview
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
instructions: project?.instructions || null,
|
||||
otherConversations,
|
||||
relevantChatHistory: chatHistory,
|
||||
links,
|
||||
documents: projectDocuments
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chat History RAG Search
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Search across project chat history using embeddings
|
||||
* Returns relevant snippets from other conversations in the project
|
||||
*/
|
||||
export async function searchProjectChatHistoryLocal(
|
||||
projectId: string,
|
||||
query: string,
|
||||
excludeConversationId?: string,
|
||||
topK: number = 10,
|
||||
threshold: number = 0.2
|
||||
): Promise<ChatHistoryResult[]> {
|
||||
const results = await searchChatHistory(query, {
|
||||
projectId,
|
||||
excludeConversationId,
|
||||
topK,
|
||||
threshold
|
||||
});
|
||||
|
||||
return results.map((r) => ({
|
||||
conversationId: r.conversationId,
|
||||
conversationTitle: r.conversationTitle,
|
||||
content: r.content,
|
||||
similarity: r.similarity
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format project context for injection into system prompt
|
||||
*/
|
||||
export function formatProjectContextForPrompt(context: ProjectContext): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Project instructions
|
||||
if (context.instructions && context.instructions.trim()) {
|
||||
parts.push(`## Project Instructions\n${context.instructions}`);
|
||||
}
|
||||
|
||||
// Project knowledge base documents with previews
|
||||
if (context.documents.length > 0) {
|
||||
const docsText = context.documents
|
||||
.map((d) => {
|
||||
let entry = `### ${d.name}\n`;
|
||||
if (d.preview) {
|
||||
entry += `${d.preview}\n`;
|
||||
} else {
|
||||
entry += `(${d.chunkCount} chunks available)\n`;
|
||||
}
|
||||
return entry;
|
||||
})
|
||||
.join('\n');
|
||||
parts.push(
|
||||
`## Project Knowledge Base\nThe following documents are available. Use this content to answer questions about the project:\n\n${docsText}`
|
||||
);
|
||||
}
|
||||
|
||||
// Other conversations in this project
|
||||
if (context.otherConversations.length > 0) {
|
||||
const conversationsText = context.otherConversations
|
||||
.slice(0, 10) // Limit to 10 most recent
|
||||
.map((c) => {
|
||||
if (c.hasSummary && c.summary) {
|
||||
return `- **${c.title}**: ${c.summary}`;
|
||||
} else {
|
||||
return `- **${c.title}** (${c.messageCount} messages, no summary yet)`;
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
parts.push(`## Other Chats in This Project\n${conversationsText}`);
|
||||
}
|
||||
|
||||
// Relevant chat history (RAG results)
|
||||
if (context.relevantChatHistory.length > 0) {
|
||||
const historyText = context.relevantChatHistory
|
||||
.map((h) => `From "${h.conversationTitle}":\n${h.content}`)
|
||||
.join('\n\n---\n\n');
|
||||
parts.push(`## Relevant Context from Past Conversations\n${historyText}`);
|
||||
}
|
||||
|
||||
// Reference links
|
||||
if (context.links.length > 0) {
|
||||
const linksText = context.links
|
||||
.slice(0, 5) // Limit to 5 links
|
||||
.map((l) => `- [${l.title}](${l.url})${l.description ? `: ${l.description}` : ''}`)
|
||||
.join('\n');
|
||||
parts.push(`## Project Reference Links\n${linksText}`);
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if project context has any content worth injecting
|
||||
*/
|
||||
export function hasProjectContext(context: ProjectContext): boolean {
|
||||
return (
|
||||
(context.instructions && context.instructions.trim().length > 0) ||
|
||||
context.documents.length > 0 ||
|
||||
context.otherConversations.length > 0 ||
|
||||
context.relevantChatHistory.length > 0 ||
|
||||
context.links.length > 0
|
||||
);
|
||||
}
|
||||
195
frontend/src/lib/services/prompt-resolution.ts
Normal file
195
frontend/src/lib/services/prompt-resolution.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ function toDomainConversation(stored: StoredConversation): Conversation {
|
||||
isPinned: stored.isPinned,
|
||||
isArchived: stored.isArchived,
|
||||
messageCount: stored.messageCount,
|
||||
systemPromptId: stored.systemPromptId ?? null
|
||||
systemPromptId: stored.systemPromptId ?? null,
|
||||
projectId: stored.projectId ?? null,
|
||||
summary: stored.summary ?? null,
|
||||
summaryUpdatedAt: stored.summaryUpdatedAt ? new Date(stored.summaryUpdatedAt) : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ export async function getConversationFull(id: string): Promise<StorageResult<Con
|
||||
* Create a new conversation
|
||||
*/
|
||||
export async function createConversation(
|
||||
data: Omit<Conversation, 'id' | 'createdAt' | 'updatedAt' | 'messageCount'>
|
||||
data: Omit<Conversation, 'id' | 'createdAt' | 'updatedAt' | 'messageCount' | 'summary' | 'summaryUpdatedAt'>
|
||||
): Promise<StorageResult<Conversation>> {
|
||||
return withErrorHandling(async () => {
|
||||
const id = generateId();
|
||||
@@ -142,7 +145,8 @@ export async function createConversation(
|
||||
isArchived: data.isArchived ?? false,
|
||||
messageCount: 0,
|
||||
syncVersion: 1,
|
||||
systemPromptId: data.systemPromptId ?? null
|
||||
systemPromptId: data.systemPromptId ?? null,
|
||||
projectId: data.projectId ?? null
|
||||
};
|
||||
|
||||
await db.conversations.add(stored);
|
||||
@@ -311,3 +315,128 @@ export async function searchConversations(query: string): Promise<StorageResult<
|
||||
return matching.map(toDomainConversation);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project-related operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all conversations for a specific project
|
||||
*/
|
||||
export async function getConversationsForProject(
|
||||
projectId: string
|
||||
): Promise<StorageResult<Conversation[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const conversations = await db.conversations
|
||||
.where('projectId')
|
||||
.equals(projectId)
|
||||
.toArray();
|
||||
|
||||
const sorted = conversations
|
||||
.filter((c) => !c.isArchived)
|
||||
.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return b.updatedAt - a.updatedAt;
|
||||
});
|
||||
|
||||
return sorted.map(toDomainConversation);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all conversations without a project (ungrouped)
|
||||
*/
|
||||
export async function getConversationsWithoutProject(): Promise<StorageResult<Conversation[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const all = await db.conversations.toArray();
|
||||
|
||||
const ungrouped = all
|
||||
.filter((c) => !c.isArchived && (!c.projectId || c.projectId === null))
|
||||
.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return b.updatedAt - a.updatedAt;
|
||||
});
|
||||
|
||||
return ungrouped.map(toDomainConversation);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a conversation to a project (or remove from project if null)
|
||||
*/
|
||||
export async function moveConversationToProject(
|
||||
conversationId: string,
|
||||
projectId: string | null
|
||||
): Promise<StorageResult<Conversation>> {
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await db.conversations.get(conversationId);
|
||||
if (!existing) {
|
||||
throw new Error(`Conversation not found: ${conversationId}`);
|
||||
}
|
||||
|
||||
const updated: StoredConversation = {
|
||||
...existing,
|
||||
projectId: projectId,
|
||||
updatedAt: Date.now(),
|
||||
syncVersion: (existing.syncVersion ?? 0) + 1
|
||||
};
|
||||
|
||||
await db.conversations.put(updated);
|
||||
await markForSync('conversation', conversationId, 'update');
|
||||
|
||||
return toDomainConversation(updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update conversation summary (for cross-chat context)
|
||||
*/
|
||||
export async function updateConversationSummary(
|
||||
conversationId: string,
|
||||
summary: string
|
||||
): Promise<StorageResult<Conversation>> {
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await db.conversations.get(conversationId);
|
||||
if (!existing) {
|
||||
throw new Error(`Conversation not found: ${conversationId}`);
|
||||
}
|
||||
|
||||
const updated: StoredConversation = {
|
||||
...existing,
|
||||
summary,
|
||||
summaryUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
syncVersion: (existing.syncVersion ?? 0) + 1
|
||||
};
|
||||
|
||||
await db.conversations.put(updated);
|
||||
return toDomainConversation(updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation summaries for all conversations in a project (excluding current)
|
||||
*/
|
||||
export async function getProjectConversationSummaries(
|
||||
projectId: string,
|
||||
excludeConversationId?: string
|
||||
): Promise<StorageResult<Array<{ id: string; title: string; summary: string; updatedAt: Date }>>> {
|
||||
return withErrorHandling(async () => {
|
||||
const conversations = await db.conversations
|
||||
.where('projectId')
|
||||
.equals(projectId)
|
||||
.toArray();
|
||||
|
||||
return conversations
|
||||
.filter((c) => !c.isArchived && c.summary && c.id !== excludeConversationId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
summary: c.summary!,
|
||||
updatedAt: new Date(c.summaryUpdatedAt ?? c.updatedAt)
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ export interface StoredConversation {
|
||||
syncVersion?: number;
|
||||
/** Optional system prompt ID for this conversation */
|
||||
systemPromptId?: string | null;
|
||||
/** Optional project ID this conversation belongs to */
|
||||
projectId?: string | null;
|
||||
/** Auto-generated conversation summary for cross-chat context */
|
||||
summary?: string | null;
|
||||
/** Timestamp when summary was last updated */
|
||||
summaryUpdatedAt?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +44,12 @@ export interface ConversationRecord {
|
||||
syncVersion?: number;
|
||||
/** Optional system prompt ID for this conversation */
|
||||
systemPromptId?: string | null;
|
||||
/** Optional project ID this conversation belongs to */
|
||||
projectId?: string | null;
|
||||
/** Auto-generated conversation summary for cross-chat context */
|
||||
summary?: string | null;
|
||||
/** Timestamp when summary was last updated */
|
||||
summaryUpdatedAt?: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +71,8 @@ export interface StoredMessage {
|
||||
siblingIndex: number;
|
||||
createdAt: number;
|
||||
syncVersion?: number;
|
||||
/** References to attachments stored in the attachments table */
|
||||
attachmentIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +99,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +155,10 @@ export interface StoredDocument {
|
||||
updatedAt: number;
|
||||
chunkCount: number;
|
||||
embeddingModel: string;
|
||||
/** Optional project ID - if set, document is project-scoped */
|
||||
projectId?: string | null;
|
||||
/** Embedding generation status: 'pending' | 'processing' | 'ready' | 'failed' */
|
||||
embeddingStatus?: 'pending' | 'processing' | 'ready' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,6 +185,85 @@ 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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project-related interfaces (v6)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Project for organizing conversations with shared context
|
||||
*/
|
||||
export interface StoredProject {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Instructions injected into system prompt for all project chats */
|
||||
instructions: string;
|
||||
/** Hex color for UI display */
|
||||
color: string;
|
||||
/** Whether folder is collapsed in sidebar */
|
||||
isCollapsed: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference link attached to a project
|
||||
*/
|
||||
export interface StoredProjectLink {
|
||||
id: string;
|
||||
projectId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message chunk with embedding for cross-chat RAG
|
||||
* Enables searching across conversation history (project-scoped or global)
|
||||
*/
|
||||
export interface StoredChatChunk {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
/** Project ID for project-scoped queries, null for global conversations */
|
||||
projectId: string | null;
|
||||
messageId: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
embedding: number[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +278,12 @@ class OllamaDatabase extends Dexie {
|
||||
documents!: Table<StoredDocument>;
|
||||
chunks!: Table<StoredChunk>;
|
||||
prompts!: Table<StoredPrompt>;
|
||||
modelSystemPrompts!: Table<StoredModelSystemPrompt>;
|
||||
modelPromptMappings!: Table<StoredModelPromptMapping>;
|
||||
// Project-related tables (v6)
|
||||
projects!: Table<StoredProject>;
|
||||
projectLinks!: Table<StoredProjectLink>;
|
||||
chatChunks!: Table<StoredChatChunk>;
|
||||
|
||||
constructor() {
|
||||
super('vessel');
|
||||
@@ -203,6 +336,44 @@ 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'
|
||||
});
|
||||
|
||||
// Version 6: Projects with cross-chat context sharing
|
||||
// Adds: projects, project links, chat chunks for RAG, projectId on conversations/documents
|
||||
this.version(6).stores({
|
||||
// Add projectId index for filtering conversations by project
|
||||
conversations: 'id, updatedAt, isPinned, isArchived, systemPromptId, projectId',
|
||||
messages: 'id, conversationId, parentId, createdAt',
|
||||
attachments: 'id, messageId',
|
||||
syncQueue: 'id, entityType, createdAt',
|
||||
// Add projectId index for project-scoped document RAG
|
||||
documents: 'id, name, createdAt, updatedAt, projectId',
|
||||
chunks: 'id, documentId',
|
||||
prompts: 'id, name, isDefault, updatedAt',
|
||||
modelSystemPrompts: 'modelName',
|
||||
modelPromptMappings: 'id, modelName, promptId',
|
||||
// Projects for organizing conversations
|
||||
projects: 'id, name, createdAt, updatedAt',
|
||||
// Reference links attached to projects
|
||||
projectLinks: 'id, projectId, createdAt',
|
||||
// Chat message chunks for cross-conversation RAG within projects
|
||||
chatChunks: 'id, conversationId, projectId, createdAt'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
125
frontend/src/lib/storage/model-prompt-mappings.ts
Normal file
125
frontend/src/lib/storage/model-prompt-mappings.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
310
frontend/src/lib/storage/projects.ts
Normal file
310
frontend/src/lib/storage/projects.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Project CRUD operations for IndexedDB storage
|
||||
*/
|
||||
|
||||
import { db, withErrorHandling, generateId } from './db.js';
|
||||
import type { StoredProject, StoredProjectLink, StorageResult } from './db.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
color: string;
|
||||
isCollapsed: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ProjectLink {
|
||||
id: string;
|
||||
projectId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateProjectData {
|
||||
name: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProjectData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
color?: string;
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateProjectLinkData {
|
||||
projectId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Converters
|
||||
// ============================================================================
|
||||
|
||||
function toDomainProject(stored: StoredProject): Project {
|
||||
return {
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
description: stored.description,
|
||||
instructions: stored.instructions,
|
||||
color: stored.color,
|
||||
isCollapsed: stored.isCollapsed,
|
||||
createdAt: new Date(stored.createdAt),
|
||||
updatedAt: new Date(stored.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function toDomainProjectLink(stored: StoredProjectLink): ProjectLink {
|
||||
return {
|
||||
id: stored.id,
|
||||
projectId: stored.projectId,
|
||||
url: stored.url,
|
||||
title: stored.title,
|
||||
description: stored.description,
|
||||
createdAt: new Date(stored.createdAt)
|
||||
};
|
||||
}
|
||||
|
||||
// Default project colors (tailwind-inspired)
|
||||
const PROJECT_COLORS = [
|
||||
'#8b5cf6', // violet-500
|
||||
'#06b6d4', // cyan-500
|
||||
'#10b981', // emerald-500
|
||||
'#f59e0b', // amber-500
|
||||
'#ef4444', // red-500
|
||||
'#ec4899', // pink-500
|
||||
'#3b82f6', // blue-500
|
||||
'#84cc16' // lime-500
|
||||
];
|
||||
|
||||
function getRandomColor(): string {
|
||||
return PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project CRUD
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all projects, sorted by name
|
||||
*/
|
||||
export async function getAllProjects(): Promise<StorageResult<Project[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const all = await db.projects.toArray();
|
||||
const sorted = all.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return sorted.map(toDomainProject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single project by ID
|
||||
*/
|
||||
export async function getProject(id: string): Promise<StorageResult<Project | null>> {
|
||||
return withErrorHandling(async () => {
|
||||
const stored = await db.projects.get(id);
|
||||
return stored ? toDomainProject(stored) : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
export async function createProject(data: CreateProjectData): Promise<StorageResult<Project>> {
|
||||
return withErrorHandling(async () => {
|
||||
const now = Date.now();
|
||||
const stored: StoredProject = {
|
||||
id: generateId(),
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
instructions: data.instructions || '',
|
||||
color: data.color || getRandomColor(),
|
||||
isCollapsed: false,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
await db.projects.add(stored);
|
||||
return toDomainProject(stored);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
export async function updateProject(
|
||||
id: string,
|
||||
updates: UpdateProjectData
|
||||
): Promise<StorageResult<Project>> {
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await db.projects.get(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Project not found: ${id}`);
|
||||
}
|
||||
|
||||
const updated: StoredProject = {
|
||||
...existing,
|
||||
...updates,
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
await db.projects.put(updated);
|
||||
return toDomainProject(updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project and all associated data
|
||||
* - Unlinks all conversations (sets projectId to null)
|
||||
* - Deletes all project links
|
||||
* - Deletes all project documents
|
||||
* - Deletes all chat chunks for the project
|
||||
*/
|
||||
export async function deleteProject(id: string): Promise<StorageResult<void>> {
|
||||
return withErrorHandling(async () => {
|
||||
await db.transaction('rw', [db.projects, db.projectLinks, db.conversations, db.documents, db.chatChunks], async () => {
|
||||
// Unlink all conversations from this project
|
||||
const conversations = await db.conversations.where('projectId').equals(id).toArray();
|
||||
for (const conv of conversations) {
|
||||
await db.conversations.update(conv.id, { projectId: null });
|
||||
}
|
||||
|
||||
// Delete all project links
|
||||
await db.projectLinks.where('projectId').equals(id).delete();
|
||||
|
||||
// Delete all project documents (and their chunks)
|
||||
const documents = await db.documents.where('projectId').equals(id).toArray();
|
||||
for (const doc of documents) {
|
||||
await db.chunks.where('documentId').equals(doc.id).delete();
|
||||
}
|
||||
await db.documents.where('projectId').equals(id).delete();
|
||||
|
||||
// Delete all chat chunks for this project
|
||||
await db.chatChunks.where('projectId').equals(id).delete();
|
||||
|
||||
// Delete the project itself
|
||||
await db.projects.delete(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle project collapse state
|
||||
*/
|
||||
export async function toggleProjectCollapse(id: string): Promise<StorageResult<boolean>> {
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await db.projects.get(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Project not found: ${id}`);
|
||||
}
|
||||
|
||||
const newState = !existing.isCollapsed;
|
||||
await db.projects.update(id, { isCollapsed: newState });
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Links CRUD
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all links for a project
|
||||
*/
|
||||
export async function getProjectLinks(projectId: string): Promise<StorageResult<ProjectLink[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const links = await db.projectLinks.where('projectId').equals(projectId).toArray();
|
||||
return links.map(toDomainProjectLink);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a link to a project
|
||||
*/
|
||||
export async function addProjectLink(data: CreateProjectLinkData): Promise<StorageResult<ProjectLink>> {
|
||||
return withErrorHandling(async () => {
|
||||
const stored: StoredProjectLink = {
|
||||
id: generateId(),
|
||||
projectId: data.projectId,
|
||||
url: data.url,
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
await db.projectLinks.add(stored);
|
||||
return toDomainProjectLink(stored);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a project link
|
||||
*/
|
||||
export async function updateProjectLink(
|
||||
id: string,
|
||||
updates: Partial<Pick<ProjectLink, 'url' | 'title' | 'description'>>
|
||||
): Promise<StorageResult<ProjectLink>> {
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await db.projectLinks.get(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Project link not found: ${id}`);
|
||||
}
|
||||
|
||||
const updated: StoredProjectLink = {
|
||||
...existing,
|
||||
...updates
|
||||
};
|
||||
|
||||
await db.projectLinks.put(updated);
|
||||
return toDomainProjectLink(updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project link
|
||||
*/
|
||||
export async function deleteProjectLink(id: string): Promise<StorageResult<void>> {
|
||||
return withErrorHandling(async () => {
|
||||
await db.projectLinks.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Statistics
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get statistics for a project
|
||||
*/
|
||||
export async function getProjectStats(projectId: string): Promise<StorageResult<{
|
||||
conversationCount: number;
|
||||
documentCount: number;
|
||||
linkCount: number;
|
||||
}>> {
|
||||
return withErrorHandling(async () => {
|
||||
const [conversations, documents, links] = await Promise.all([
|
||||
db.conversations.where('projectId').equals(projectId).count(),
|
||||
db.documents.where('projectId').equals(projectId).count(),
|
||||
db.projectLinks.where('projectId').equals(projectId).count()
|
||||
]);
|
||||
|
||||
return {
|
||||
conversationCount: conversations,
|
||||
documentCount: documents,
|
||||
linkCount: links
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -204,6 +204,66 @@ export class ConversationsState {
|
||||
setSystemPrompt(id: string, systemPromptId: string | null): void {
|
||||
this.update(id, { systemPromptId });
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Project-related methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Get conversations for a specific project
|
||||
* @param projectId The project ID
|
||||
*/
|
||||
forProject(projectId: string): Conversation[] {
|
||||
return this.items
|
||||
.filter((c) => !c.isArchived && c.projectId === projectId)
|
||||
.sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
||||
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversations without a project
|
||||
*/
|
||||
withoutProject(): Conversation[] {
|
||||
return this.items
|
||||
.filter((c) => !c.isArchived && (!c.projectId || c.projectId === null))
|
||||
.sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
||||
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a conversation to a project (or remove from project if null)
|
||||
* @param id The conversation ID
|
||||
* @param projectId The project ID (or null to remove from project)
|
||||
*/
|
||||
moveToProject(id: string, projectId: string | null): void {
|
||||
this.update(id, { projectId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a conversation's summary
|
||||
* @param id The conversation ID
|
||||
* @param summary The summary text
|
||||
*/
|
||||
updateSummary(id: string, summary: string): void {
|
||||
this.update(id, { summary, summaryUpdatedAt: new Date() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all project IDs that have conversations
|
||||
*/
|
||||
getProjectIdsWithConversations(): string[] {
|
||||
const projectIds = new Set<string>();
|
||||
for (const c of this.items) {
|
||||
if (!c.isArchived && c.projectId) {
|
||||
projectIds.add(c.projectId);
|
||||
}
|
||||
}
|
||||
return Array.from(projectIds);
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton conversations state instance */
|
||||
|
||||
@@ -9,8 +9,10 @@ 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';
|
||||
export { ProjectsState, projectsState } from './projects.svelte.js';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { GroupedConversations } from './conversations.svelte.js';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
120
frontend/src/lib/stores/model-creation.svelte.ts
Normal file
120
frontend/src/lib/stores/model-creation.svelte.ts
Normal 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();
|
||||
150
frontend/src/lib/stores/model-prompt-mappings.svelte.ts
Normal file
150
frontend/src/lib/stores/model-prompt-mappings.svelte.ts
Normal 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();
|
||||
@@ -23,12 +23,10 @@ export const CAPABILITY_INFO: Record<string, { label: string; icon: string; colo
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware models that should NOT appear in the chat model selector
|
||||
* These are special-purpose models for embeddings, function routing, etc.
|
||||
* Embedding model patterns for semantic search/RAG
|
||||
*/
|
||||
const MIDDLEWARE_MODEL_PATTERNS = [
|
||||
const EMBEDDING_MODEL_PATTERNS = [
|
||||
'embeddinggemma',
|
||||
'functiongemma',
|
||||
'nomic-embed',
|
||||
'mxbai-embed',
|
||||
'all-minilm',
|
||||
@@ -36,7 +34,16 @@ const MIDDLEWARE_MODEL_PATTERNS = [
|
||||
'bge-', // BGE embedding models
|
||||
'e5-', // E5 embedding models
|
||||
'gte-', // GTE embedding models
|
||||
'embed' // Generic embed pattern (catches most embedding models)
|
||||
'embed' // Generic embed pattern
|
||||
];
|
||||
|
||||
/**
|
||||
* Middleware models that should NOT appear in the chat model selector
|
||||
* These are special-purpose models for embeddings, function routing, etc.
|
||||
*/
|
||||
const MIDDLEWARE_MODEL_PATTERNS = [
|
||||
...EMBEDDING_MODEL_PATTERNS,
|
||||
'functiongemma' // Function routing model
|
||||
];
|
||||
|
||||
/** Check if a model is a middleware/utility model (not for direct chat) */
|
||||
@@ -45,6 +52,12 @@ function isMiddlewareModel(model: OllamaModel): boolean {
|
||||
return MIDDLEWARE_MODEL_PATTERNS.some((pattern) => name.includes(pattern));
|
||||
}
|
||||
|
||||
/** Check if a model is an embedding model */
|
||||
function isEmbeddingModel(model: OllamaModel): boolean {
|
||||
const name = model.name.toLowerCase();
|
||||
return EMBEDDING_MODEL_PATTERNS.some((pattern) => name.includes(pattern));
|
||||
}
|
||||
|
||||
/** Check if a model supports vision */
|
||||
function isVisionModel(model: OllamaModel): boolean {
|
||||
const name = model.name.toLowerCase();
|
||||
@@ -139,6 +152,16 @@ export class ModelsState {
|
||||
return this.available.filter(isVisionModel);
|
||||
});
|
||||
|
||||
// Derived: Embedding models available for RAG/semantic search
|
||||
embeddingModels = $derived.by(() => {
|
||||
return this.available.filter(isEmbeddingModel);
|
||||
});
|
||||
|
||||
// Derived: Check if any embedding model is available
|
||||
hasEmbeddingModel = $derived.by(() => {
|
||||
return this.embeddingModels.length > 0;
|
||||
});
|
||||
|
||||
// Derived: Check if selected model supports vision
|
||||
// Uses capabilities cache first (from Ollama API), falls back to pattern matching
|
||||
selectedSupportsVision = $derived.by(() => {
|
||||
|
||||
150
frontend/src/lib/stores/projects.svelte.ts
Normal file
150
frontend/src/lib/stores/projects.svelte.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Projects state management using Svelte 5 runes
|
||||
* Handles project list, selection, and CRUD operations
|
||||
*/
|
||||
|
||||
import type { Project, ProjectLink } from '$lib/storage/projects.js';
|
||||
import * as projectStorage from '$lib/storage/projects.js';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { Project, ProjectLink };
|
||||
|
||||
/** Projects state class with reactive properties */
|
||||
export class ProjectsState {
|
||||
// Core state
|
||||
projects = $state<Project[]>([]);
|
||||
activeProjectId = $state<string | null>(null);
|
||||
isLoading = $state(false);
|
||||
hasLoaded = $state(false); // True after first successful load
|
||||
error = $state<string | null>(null);
|
||||
|
||||
// Derived: Active project
|
||||
activeProject = $derived.by(() => {
|
||||
if (!this.activeProjectId) return null;
|
||||
return this.projects.find((p) => p.id === this.activeProjectId) ?? null;
|
||||
});
|
||||
|
||||
// Derived: Projects sorted by name
|
||||
sortedProjects = $derived.by(() => {
|
||||
return [...this.projects].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
// Derived: Collapsed project IDs for quick lookup
|
||||
collapsedIds = $derived.by(() => {
|
||||
return new Set(this.projects.filter((p) => p.isCollapsed).map((p) => p.id));
|
||||
});
|
||||
|
||||
/**
|
||||
* Load all projects from storage
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const result = await projectStorage.getAllProjects();
|
||||
if (result.success) {
|
||||
this.projects = result.data;
|
||||
this.hasLoaded = true;
|
||||
} else {
|
||||
this.error = result.error;
|
||||
console.error('[ProjectsState] Failed to load projects:', result.error);
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
async add(data: projectStorage.CreateProjectData): Promise<Project | null> {
|
||||
this.error = null;
|
||||
|
||||
const result = await projectStorage.createProject(data);
|
||||
if (result.success) {
|
||||
this.projects = [...this.projects, result.data];
|
||||
return result.data;
|
||||
} else {
|
||||
this.error = result.error;
|
||||
console.error('[ProjectsState] Failed to create project:', result.error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
async update(id: string, updates: projectStorage.UpdateProjectData): Promise<boolean> {
|
||||
this.error = null;
|
||||
|
||||
const result = await projectStorage.updateProject(id, updates);
|
||||
if (result.success) {
|
||||
this.projects = this.projects.map((p) =>
|
||||
p.id === id ? result.data : p
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
this.error = result.error;
|
||||
console.error('[ProjectsState] Failed to update project:', result.error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
async remove(id: string): Promise<boolean> {
|
||||
this.error = null;
|
||||
|
||||
const result = await projectStorage.deleteProject(id);
|
||||
if (result.success) {
|
||||
this.projects = this.projects.filter((p) => p.id !== id);
|
||||
// Clear active project if it was deleted
|
||||
if (this.activeProjectId === id) {
|
||||
this.activeProjectId = null;
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
this.error = result.error;
|
||||
console.error('[ProjectsState] Failed to delete project:', result.error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle project collapse state
|
||||
*/
|
||||
async toggleCollapse(id: string): Promise<void> {
|
||||
const result = await projectStorage.toggleProjectCollapse(id);
|
||||
if (result.success) {
|
||||
this.projects = this.projects.map((p) =>
|
||||
p.id === id ? { ...p, isCollapsed: result.data } : p
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active project (for filtering)
|
||||
*/
|
||||
setActive(id: string | null): void {
|
||||
this.activeProjectId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a project by ID
|
||||
*/
|
||||
find(id: string): Project | undefined {
|
||||
return this.projects.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project is collapsed
|
||||
*/
|
||||
isCollapsed(id: string): boolean {
|
||||
return this.collapsedIds.has(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton projects state instance */
|
||||
export const projectsState = new ProjectsState();
|
||||
@@ -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);
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
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';
|
||||
import { DEFAULT_EMBEDDING_MODEL } from '$lib/memory/embeddings';
|
||||
|
||||
const STORAGE_KEY = 'vessel-settings';
|
||||
|
||||
@@ -30,6 +34,14 @@ 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);
|
||||
|
||||
// Embedding model for semantic search
|
||||
embeddingModel = $state(DEFAULT_EMBEDDING_MODEL);
|
||||
|
||||
// Derived: Current model parameters object
|
||||
modelParameters = $derived.by((): ModelParameters => ({
|
||||
temperature: this.temperature,
|
||||
@@ -141,6 +153,40 @@ export class SettingsState {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update embedding model for semantic search
|
||||
*/
|
||||
updateEmbeddingModel(model: string): void {
|
||||
this.embeddingModel = model;
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from localStorage
|
||||
*/
|
||||
@@ -151,11 +197,20 @@ 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;
|
||||
|
||||
// Embedding model
|
||||
this.embeddingModel = settings.embeddingModel ?? DEFAULT_EMBEDDING_MODEL;
|
||||
} catch (error) {
|
||||
console.warn('[Settings] Failed to load from localStorage:', error);
|
||||
}
|
||||
@@ -168,7 +223,13 @@ 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
|
||||
},
|
||||
embeddingModel: this.embeddingModel
|
||||
};
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -370,3 +370,94 @@ export function updateToolCallState(
|
||||
endTime: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing text-based tool calls from content
|
||||
*/
|
||||
export interface TextToolCallParseResult {
|
||||
/** Any tool calls found in the content */
|
||||
toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>;
|
||||
/** Content with tool calls removed (for display) */
|
||||
cleanContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse text-based tool calls from model output
|
||||
*
|
||||
* Models without native function calling may output tool calls as plain text
|
||||
* in formats like:
|
||||
* - tool_name[ARGS]{json}
|
||||
* - <tool_call>{"name": "...", "arguments": {...}}</tool_call>
|
||||
*
|
||||
* This function detects and parses these formats.
|
||||
*/
|
||||
export function parseTextToolCalls(content: string): TextToolCallParseResult {
|
||||
const toolCalls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
|
||||
let cleanContent = content;
|
||||
|
||||
// Pattern 1: tool_name[ARGS]{json} or tool_name[ARGS]{"key": "value"}
|
||||
const argsPattern = /(\w+)\[ARGS\]\s*(\{[\s\S]*?\})/g;
|
||||
const argsMatches = [...content.matchAll(argsPattern)];
|
||||
|
||||
for (const match of argsMatches) {
|
||||
const [fullMatch, toolName, argsJson] = match;
|
||||
try {
|
||||
const args = JSON.parse(argsJson);
|
||||
toolCalls.push({ name: toolName, arguments: args });
|
||||
cleanContent = cleanContent.replace(fullMatch, '').trim();
|
||||
} catch {
|
||||
// JSON parse failed, skip this match
|
||||
console.warn(`Failed to parse tool call arguments: ${argsJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: <tool_call>{"name": "tool_name", "arguments": {...}}</tool_call>
|
||||
const xmlPattern = /<tool_call>\s*(\{[\s\S]*?\})\s*<\/tool_call>/g;
|
||||
const xmlMatches = [...content.matchAll(xmlPattern)];
|
||||
|
||||
for (const match of xmlMatches) {
|
||||
const [fullMatch, json] = match;
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (parsed.name && parsed.arguments) {
|
||||
toolCalls.push({
|
||||
name: parsed.name,
|
||||
arguments: typeof parsed.arguments === 'string'
|
||||
? JSON.parse(parsed.arguments)
|
||||
: parsed.arguments
|
||||
});
|
||||
cleanContent = cleanContent.replace(fullMatch, '').trim();
|
||||
}
|
||||
} catch {
|
||||
console.warn(`Failed to parse XML tool call: ${json}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 3: {"tool_calls": [{"function": {"name": "...", "arguments": {...}}}]}
|
||||
const jsonBlobPattern = /\{[\s\S]*?"tool_calls"\s*:\s*\[[\s\S]*?\]\s*\}/g;
|
||||
const jsonMatches = [...content.matchAll(jsonBlobPattern)];
|
||||
|
||||
for (const match of jsonMatches) {
|
||||
const [fullMatch] = match;
|
||||
try {
|
||||
const parsed = JSON.parse(fullMatch);
|
||||
if (Array.isArray(parsed.tool_calls)) {
|
||||
for (const tc of parsed.tool_calls) {
|
||||
if (tc.function?.name) {
|
||||
toolCalls.push({
|
||||
name: tc.function.name,
|
||||
arguments: typeof tc.function.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments)
|
||||
: tc.function.arguments || {}
|
||||
});
|
||||
}
|
||||
}
|
||||
cleanContent = cleanContent.replace(fullMatch, '').trim();
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
return { toolCalls, cleanContent };
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ export {
|
||||
toolRegistry,
|
||||
executeCustomTool,
|
||||
parseToolCall,
|
||||
parseTextToolCalls,
|
||||
runToolCall,
|
||||
runToolCalls,
|
||||
formatToolResultsForChat,
|
||||
createToolCallState,
|
||||
updateToolCallState
|
||||
updateToolCallState,
|
||||
type TextToolCallParseResult
|
||||
} from './executor.js';
|
||||
export {
|
||||
PREFERRED_FUNCTION_MODEL,
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface ToolTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'api' | 'data' | 'utility' | 'integration';
|
||||
category: 'api' | 'data' | 'utility' | 'integration' | 'agentic';
|
||||
language: ToolImplementation;
|
||||
code: string;
|
||||
parameters: JSONSchema;
|
||||
@@ -166,6 +166,184 @@ return {
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
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',
|
||||
@@ -336,6 +514,580 @@ print(json.dumps(result))`,
|
||||
},
|
||||
required: ['text', 'operation']
|
||||
}
|
||||
},
|
||||
|
||||
// Agentic Templates
|
||||
{
|
||||
id: 'js-task-manager',
|
||||
name: 'Task Manager',
|
||||
description:
|
||||
'TASK TRACKING: Use when the user mentions tasks, todos, or things to do. Actions: add (create task), complete (mark done), list (show all), remove (delete). Persists across conversations. Use for any "add to my list", "what tasks", "mark as done" requests.',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Task Manager with localStorage persistence
|
||||
const STORAGE_KEY = 'vessel_agent_tasks';
|
||||
|
||||
const loadTasks = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const saveTasks = (tasks) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
|
||||
};
|
||||
|
||||
const action = args.action;
|
||||
let tasks = loadTasks();
|
||||
|
||||
switch (action) {
|
||||
case 'create': {
|
||||
const task = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
title: args.title,
|
||||
description: args.description || '',
|
||||
priority: args.priority || 'medium',
|
||||
status: 'pending',
|
||||
created: new Date().toISOString(),
|
||||
due: args.due || null,
|
||||
tags: args.tags || []
|
||||
};
|
||||
tasks.push(task);
|
||||
saveTasks(tasks);
|
||||
return { success: true, task, message: 'Task created' };
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
let filtered = tasks;
|
||||
if (args.status) filtered = filtered.filter(t => t.status === args.status);
|
||||
if (args.priority) filtered = filtered.filter(t => t.priority === args.priority);
|
||||
if (args.tag) filtered = filtered.filter(t => t.tags?.includes(args.tag));
|
||||
return {
|
||||
tasks: filtered,
|
||||
total: tasks.length,
|
||||
pending: tasks.filter(t => t.status === 'pending').length,
|
||||
completed: tasks.filter(t => t.status === 'completed').length
|
||||
};
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
const idx = tasks.findIndex(t => t.id === args.id);
|
||||
if (idx === -1) return { error: 'Task not found' };
|
||||
if (args.title) tasks[idx].title = args.title;
|
||||
if (args.description !== undefined) tasks[idx].description = args.description;
|
||||
if (args.priority) tasks[idx].priority = args.priority;
|
||||
if (args.status) tasks[idx].status = args.status;
|
||||
if (args.due !== undefined) tasks[idx].due = args.due;
|
||||
if (args.tags) tasks[idx].tags = args.tags;
|
||||
tasks[idx].updated = new Date().toISOString();
|
||||
saveTasks(tasks);
|
||||
return { success: true, task: tasks[idx], message: 'Task updated' };
|
||||
}
|
||||
|
||||
case 'complete': {
|
||||
const idx = tasks.findIndex(t => t.id === args.id);
|
||||
if (idx === -1) return { error: 'Task not found' };
|
||||
tasks[idx].status = 'completed';
|
||||
tasks[idx].completedAt = new Date().toISOString();
|
||||
saveTasks(tasks);
|
||||
return { success: true, task: tasks[idx], message: 'Task completed' };
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const idx = tasks.findIndex(t => t.id === args.id);
|
||||
if (idx === -1) return { error: 'Task not found' };
|
||||
const deleted = tasks.splice(idx, 1)[0];
|
||||
saveTasks(tasks);
|
||||
return { success: true, deleted, message: 'Task deleted' };
|
||||
}
|
||||
|
||||
case 'clear_completed': {
|
||||
const before = tasks.length;
|
||||
tasks = tasks.filter(t => t.status !== 'completed');
|
||||
saveTasks(tasks);
|
||||
return { success: true, removed: before - tasks.length, remaining: tasks.length };
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: 'Unknown action. Use: create, list, update, complete, delete, clear_completed' };
|
||||
}`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Action: create, list, update, complete, delete, clear_completed'
|
||||
},
|
||||
id: { type: 'string', description: 'Task ID (for update/complete/delete)' },
|
||||
title: { type: 'string', description: 'Task title (for create/update)' },
|
||||
description: { type: 'string', description: 'Task description' },
|
||||
priority: { type: 'string', description: 'Priority: low, medium, high, urgent' },
|
||||
status: { type: 'string', description: 'Filter/set status: pending, in_progress, completed' },
|
||||
due: { type: 'string', description: 'Due date (ISO format)' },
|
||||
tags: { type: 'array', description: 'Tags for categorization' },
|
||||
tag: { type: 'string', description: 'Filter by tag (for list)' }
|
||||
},
|
||||
required: ['action']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'js-memory-store',
|
||||
name: 'Memory Store',
|
||||
description:
|
||||
'PERSISTENT MEMORY: Use this tool whenever the user asks you to remember something, recall memories, list what you remember, or forget something. Actions: store (save new memory), recall (retrieve memories), list (show all memories), forget (delete memory), clear (delete all). This gives you persistent memory across conversations.',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Memory Store - persistent key-value storage for agent context
|
||||
const STORAGE_KEY = 'vessel_agent_memory';
|
||||
|
||||
const loadMemory = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const saveMemory = (mem) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(mem));
|
||||
};
|
||||
|
||||
const action = args.action;
|
||||
let memory = loadMemory();
|
||||
|
||||
switch (action) {
|
||||
case 'store': {
|
||||
const key = args.key;
|
||||
const value = args.value;
|
||||
const category = args.category || 'general';
|
||||
|
||||
// Validate required fields
|
||||
if (!key) return { error: 'Key is required for store action' };
|
||||
if (value === undefined || value === null) return { error: 'Value is required for store action' };
|
||||
|
||||
if (!memory[category]) memory[category] = {};
|
||||
memory[category][key] = {
|
||||
value,
|
||||
stored: new Date().toISOString(),
|
||||
accessCount: 0
|
||||
};
|
||||
saveMemory(memory);
|
||||
return { success: true, key, category, value, message: 'Memory stored' };
|
||||
}
|
||||
|
||||
case 'recall': {
|
||||
const key = args.key;
|
||||
const category = args.category;
|
||||
|
||||
if (category && key) {
|
||||
const item = memory[category]?.[key];
|
||||
if (!item) return { found: false, key, category };
|
||||
item.accessCount++;
|
||||
item.lastAccess = new Date().toISOString();
|
||||
saveMemory(memory);
|
||||
return { found: true, key, category, value: item.value, stored: item.stored };
|
||||
}
|
||||
|
||||
if (category) {
|
||||
// Return formatted entries for category (consistent with list)
|
||||
const items = memory[category] || {};
|
||||
const entries = Object.entries(items).map(([k, data]) => ({
|
||||
key: k,
|
||||
value: data.value,
|
||||
stored: data.stored
|
||||
}));
|
||||
return { found: entries.length > 0, category, entries, count: entries.length };
|
||||
}
|
||||
|
||||
if (key) {
|
||||
// Search across all categories
|
||||
for (const cat in memory) {
|
||||
if (memory[cat][key]) {
|
||||
memory[cat][key].accessCount++;
|
||||
saveMemory(memory);
|
||||
return { found: true, key, category: cat, value: memory[cat][key].value };
|
||||
}
|
||||
}
|
||||
return { found: false, key };
|
||||
}
|
||||
|
||||
// No key or category provided - return all memories (like list)
|
||||
const allMemories = {};
|
||||
for (const cat in memory) {
|
||||
allMemories[cat] = Object.entries(memory[cat]).map(([k, data]) => ({
|
||||
key: k,
|
||||
value: data.value,
|
||||
stored: data.stored
|
||||
}));
|
||||
}
|
||||
return {
|
||||
memories: allMemories,
|
||||
totalCategories: Object.keys(memory).length,
|
||||
totalEntries: Object.values(memory).reduce((sum, cat) => sum + Object.keys(cat).length, 0)
|
||||
};
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const category = args.category;
|
||||
if (category) {
|
||||
const items = memory[category] || {};
|
||||
const entries = Object.entries(items).map(([key, data]) => ({
|
||||
key,
|
||||
value: data.value,
|
||||
stored: data.stored
|
||||
}));
|
||||
return {
|
||||
category,
|
||||
entries,
|
||||
count: entries.length
|
||||
};
|
||||
}
|
||||
// List all categories with their entries
|
||||
const allMemories = {};
|
||||
for (const cat in memory) {
|
||||
allMemories[cat] = Object.entries(memory[cat]).map(([key, data]) => ({
|
||||
key,
|
||||
value: data.value,
|
||||
stored: data.stored
|
||||
}));
|
||||
}
|
||||
return {
|
||||
memories: allMemories,
|
||||
totalCategories: Object.keys(memory).length,
|
||||
totalEntries: Object.values(memory).reduce((sum, cat) => sum + Object.keys(cat).length, 0)
|
||||
};
|
||||
}
|
||||
|
||||
case 'forget': {
|
||||
const key = args.key;
|
||||
const category = args.category;
|
||||
|
||||
if (category && key) {
|
||||
if (memory[category]?.[key]) {
|
||||
delete memory[category][key];
|
||||
if (Object.keys(memory[category]).length === 0) delete memory[category];
|
||||
saveMemory(memory);
|
||||
return { success: true, forgotten: key, category };
|
||||
}
|
||||
return { error: 'Memory not found', key, category };
|
||||
}
|
||||
|
||||
if (category) {
|
||||
if (!memory[category]) {
|
||||
return { error: 'Category not found', category };
|
||||
}
|
||||
const count = Object.keys(memory[category]).length;
|
||||
delete memory[category];
|
||||
saveMemory(memory);
|
||||
return { success: true, forgotten: category, type: 'category', entriesRemoved: count };
|
||||
}
|
||||
|
||||
return { error: 'Provide key and/or category to forget' };
|
||||
}
|
||||
|
||||
case 'clear': {
|
||||
const before = Object.keys(memory).length;
|
||||
memory = {};
|
||||
saveMemory(memory);
|
||||
return { success: true, cleared: before, message: 'All memory cleared' };
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: 'Unknown action. Use: store, recall, list, forget, clear' };
|
||||
}`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Required. Use "list" or "recall" to show memories, "store" to save new memory, "forget" to delete, "clear" to erase all'
|
||||
},
|
||||
key: { type: 'string', description: 'Unique identifier for the memory (e.g., "user_name", "favorite_color")' },
|
||||
value: { type: 'string', description: 'The information to remember (required for store action)' },
|
||||
category: { type: 'string', description: 'Optional grouping (e.g., "preferences", "facts", "context"). Defaults to "general"' }
|
||||
},
|
||||
required: ['action']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'js-think-step-by-step',
|
||||
name: 'Structured Thinking',
|
||||
description:
|
||||
'REASONING: Use for complex questions requiring step-by-step analysis. Helps you think through problems systematically before answering. Use when facing multi-part questions, logical puzzles, or decisions requiring careful thought.',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Structured Thinking - explicit step-by-step reasoning
|
||||
const problem = args.problem;
|
||||
const steps = args.steps || [];
|
||||
const conclusion = args.conclusion;
|
||||
const confidence = args.confidence || 'medium';
|
||||
|
||||
const analysis = {
|
||||
problem: problem,
|
||||
reasoning: {
|
||||
steps: steps.map((step, i) => ({
|
||||
step: i + 1,
|
||||
thought: step,
|
||||
type: step.toLowerCase().includes('assume') ? 'assumption' :
|
||||
step.toLowerCase().includes('if') ? 'conditional' :
|
||||
step.toLowerCase().includes('because') ? 'justification' :
|
||||
step.toLowerCase().includes('therefore') ? 'inference' :
|
||||
'observation'
|
||||
})),
|
||||
stepCount: steps.length
|
||||
},
|
||||
conclusion: conclusion,
|
||||
confidence: confidence,
|
||||
confidenceScore: confidence === 'high' ? 0.9 :
|
||||
confidence === 'medium' ? 0.7 :
|
||||
confidence === 'low' ? 0.4 : 0.5,
|
||||
metadata: {
|
||||
hasAssumptions: steps.some(s => s.toLowerCase().includes('assume')),
|
||||
hasConditionals: steps.some(s => s.toLowerCase().includes('if')),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
// Add quality indicators
|
||||
analysis.quality = {
|
||||
hasMultipleSteps: steps.length >= 3,
|
||||
hasConclusion: !!conclusion,
|
||||
isWellStructured: steps.length >= 2 && !!conclusion,
|
||||
suggestions: []
|
||||
};
|
||||
|
||||
if (steps.length < 2) {
|
||||
analysis.quality.suggestions.push('Consider breaking down into more steps');
|
||||
}
|
||||
if (!conclusion) {
|
||||
analysis.quality.suggestions.push('Add a clear conclusion');
|
||||
}
|
||||
if (confidence === 'low') {
|
||||
analysis.quality.suggestions.push('Identify what additional information would increase confidence');
|
||||
}
|
||||
|
||||
return analysis;`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
problem: {
|
||||
type: 'string',
|
||||
description: 'The problem or question to reason about'
|
||||
},
|
||||
steps: {
|
||||
type: 'array',
|
||||
description: 'Array of reasoning steps, each a string explaining one step of thought'
|
||||
},
|
||||
conclusion: {
|
||||
type: 'string',
|
||||
description: 'The final conclusion reached'
|
||||
},
|
||||
confidence: {
|
||||
type: 'string',
|
||||
description: 'Confidence level: low, medium, high'
|
||||
}
|
||||
},
|
||||
required: ['problem', 'steps']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'js-decision-matrix',
|
||||
name: 'Decision Matrix',
|
||||
description:
|
||||
'DECISION HELPER: Use when comparing multiple options, recommending choices, or evaluating trade-offs. Scores options against weighted criteria. Perfect for "which should I choose", "compare X vs Y", or recommendation requests.',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Decision Matrix - weighted multi-criteria decision analysis
|
||||
const options = args.options || [];
|
||||
const criteria = args.criteria || [];
|
||||
const scores = args.scores || {};
|
||||
|
||||
if (options.length === 0) {
|
||||
return { error: 'Provide at least one option' };
|
||||
}
|
||||
if (criteria.length === 0) {
|
||||
return { error: 'Provide at least one criterion with name and weight' };
|
||||
}
|
||||
|
||||
// Normalize weights
|
||||
const totalWeight = criteria.reduce((sum, c) => sum + (c.weight || 1), 0);
|
||||
const normalizedCriteria = criteria.map(c => ({
|
||||
name: c.name,
|
||||
weight: (c.weight || 1) / totalWeight,
|
||||
originalWeight: c.weight || 1
|
||||
}));
|
||||
|
||||
// Calculate weighted scores for each option
|
||||
const results = options.map(option => {
|
||||
let totalScore = 0;
|
||||
const breakdown = [];
|
||||
|
||||
for (const criterion of normalizedCriteria) {
|
||||
const score = scores[option]?.[criterion.name] ?? 5; // Default to 5/10
|
||||
const weighted = score * criterion.weight;
|
||||
totalScore += weighted;
|
||||
breakdown.push({
|
||||
criterion: criterion.name,
|
||||
rawScore: score,
|
||||
weight: Math.round(criterion.weight * 100) + '%',
|
||||
weightedScore: Math.round(weighted * 100) / 100
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
option,
|
||||
totalScore: Math.round(totalScore * 100) / 100,
|
||||
maxPossible: 10,
|
||||
percentage: Math.round(totalScore * 10) + '%',
|
||||
breakdown
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score
|
||||
results.sort((a, b) => b.totalScore - a.totalScore);
|
||||
|
||||
// Identify winner and insights
|
||||
const winner = results[0];
|
||||
const runnerUp = results[1];
|
||||
const margin = runnerUp ? Math.round((winner.totalScore - runnerUp.totalScore) * 100) / 100 : null;
|
||||
|
||||
return {
|
||||
recommendation: winner.option,
|
||||
confidence: margin > 1.5 ? 'high' : margin > 0.5 ? 'medium' : 'low',
|
||||
margin: margin,
|
||||
rankings: results,
|
||||
criteria: normalizedCriteria.map(c => ({
|
||||
name: c.name,
|
||||
weight: Math.round(c.weight * 100) + '%'
|
||||
})),
|
||||
insight: margin && margin < 0.5 ?
|
||||
'Options are very close - consider additional criteria or qualitative factors' :
|
||||
margin && margin > 2 ?
|
||||
\`\${winner.option} is a clear winner with significant margin\` :
|
||||
'Decision is reasonably clear but review the breakdown for nuance'
|
||||
};`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Array of option names to evaluate (e.g., ["Option A", "Option B"])'
|
||||
},
|
||||
criteria: {
|
||||
type: 'array',
|
||||
description: 'Array of criteria objects with name and weight (e.g., [{"name": "Cost", "weight": 3}, {"name": "Quality", "weight": 2}])'
|
||||
},
|
||||
scores: {
|
||||
type: 'object',
|
||||
description: 'Scores object: { "Option A": { "Cost": 8, "Quality": 7 }, "Option B": { "Cost": 6, "Quality": 9 } }'
|
||||
}
|
||||
},
|
||||
required: ['options', 'criteria', 'scores']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'js-project-planner',
|
||||
name: 'Project Planner',
|
||||
description:
|
||||
'PROJECT BREAKDOWN: Use when planning projects, creating roadmaps, or breaking work into phases. Helps structure complex initiatives with tasks, dependencies, and milestones. Use for "help me plan", "break this down", or project planning requests.',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Project Planner - decompose projects into actionable plans
|
||||
const projectName = args.project_name;
|
||||
const goal = args.goal;
|
||||
const phases = args.phases || [];
|
||||
const constraints = args.constraints || [];
|
||||
|
||||
if (!projectName || !goal) {
|
||||
return { error: 'Provide project_name and goal' };
|
||||
}
|
||||
|
||||
const plan = {
|
||||
project: projectName,
|
||||
goal: goal,
|
||||
created: new Date().toISOString(),
|
||||
constraints: constraints,
|
||||
phases: phases.map((phase, phaseIdx) => ({
|
||||
id: \`phase-\${phaseIdx + 1}\`,
|
||||
name: phase.name,
|
||||
description: phase.description || '',
|
||||
order: phaseIdx + 1,
|
||||
tasks: (phase.tasks || []).map((task, taskIdx) => ({
|
||||
id: \`\${phaseIdx + 1}.\${taskIdx + 1}\`,
|
||||
title: task.title || task,
|
||||
description: task.description || '',
|
||||
dependencies: task.dependencies || [],
|
||||
status: 'pending',
|
||||
priority: task.priority || 'medium'
|
||||
})),
|
||||
deliverables: phase.deliverables || []
|
||||
})),
|
||||
summary: {
|
||||
totalPhases: phases.length,
|
||||
totalTasks: phases.reduce((sum, p) => sum + (p.tasks?.length || 0), 0),
|
||||
hasConstraints: constraints.length > 0
|
||||
}
|
||||
};
|
||||
|
||||
// Identify critical path (tasks with most dependents)
|
||||
const allTasks = plan.phases.flatMap(p => p.tasks);
|
||||
const dependencyCounts = {};
|
||||
allTasks.forEach(t => {
|
||||
t.dependencies.forEach(dep => {
|
||||
dependencyCounts[dep] = (dependencyCounts[dep] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
plan.criticalTasks = Object.entries(dependencyCounts)
|
||||
.filter(([_, count]) => count > 1)
|
||||
.map(([id, count]) => ({ taskId: id, dependentCount: count }))
|
||||
.sort((a, b) => b.dependentCount - a.dependentCount);
|
||||
|
||||
// Generate next actions (tasks with no pending dependencies)
|
||||
const completedTasks = new Set();
|
||||
plan.nextActions = allTasks
|
||||
.filter(t => t.dependencies.every(d => completedTasks.has(d)))
|
||||
.slice(0, 5)
|
||||
.map(t => ({ id: t.id, title: t.title, phase: t.id.split('.')[0] }));
|
||||
|
||||
// Validation
|
||||
plan.validation = {
|
||||
isValid: phases.length > 0 && plan.summary.totalTasks > 0,
|
||||
warnings: []
|
||||
};
|
||||
|
||||
if (phases.length === 0) {
|
||||
plan.validation.warnings.push('No phases defined');
|
||||
}
|
||||
if (plan.summary.totalTasks === 0) {
|
||||
plan.validation.warnings.push('No tasks defined');
|
||||
}
|
||||
if (constraints.length === 0) {
|
||||
plan.validation.warnings.push('Consider adding constraints (time, budget, resources)');
|
||||
}
|
||||
|
||||
return plan;`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project_name: {
|
||||
type: 'string',
|
||||
description: 'Name of the project'
|
||||
},
|
||||
goal: {
|
||||
type: 'string',
|
||||
description: 'The main goal or outcome of the project'
|
||||
},
|
||||
phases: {
|
||||
type: 'array',
|
||||
description: 'Array of phase objects: [{ name, description, tasks: [{ title, dependencies, priority }], deliverables }]'
|
||||
},
|
||||
constraints: {
|
||||
type: 'array',
|
||||
description: 'Array of constraints (e.g., ["Budget: $10k", "Timeline: 2 weeks"])'
|
||||
}
|
||||
},
|
||||
required: ['project_name', 'goal']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -16,6 +16,12 @@ export interface Conversation {
|
||||
messageCount: number;
|
||||
/** Optional system prompt ID for this conversation (null = use global default) */
|
||||
systemPromptId?: string | null;
|
||||
/** Optional project ID this conversation belongs to */
|
||||
projectId?: string | null;
|
||||
/** Auto-generated conversation summary for cross-chat context */
|
||||
summary?: string | null;
|
||||
/** Timestamp when summary was last updated */
|
||||
summaryUpdatedAt?: Date | null;
|
||||
}
|
||||
|
||||
/** Full conversation including message tree and navigation state */
|
||||
|
||||
@@ -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,12 @@ export interface ChatSettings {
|
||||
|
||||
/** Custom model parameters (used when useCustomParameters is true) */
|
||||
modelParameters: ModelParameters;
|
||||
|
||||
/** Auto-compact settings for context management */
|
||||
autoCompact?: AutoCompactSettings;
|
||||
|
||||
/** Embedding model for semantic search (e.g., 'nomic-embed-text') */
|
||||
embeddingModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,5 +130,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 }
|
||||
};
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { chatState, conversationsState, modelsState, uiState, promptsState, versionState } from '$lib/stores';
|
||||
import { chatState, conversationsState, modelsState, uiState, promptsState, versionState, projectsState } from '$lib/stores';
|
||||
import { getAllConversations } from '$lib/storage';
|
||||
import { syncManager } from '$lib/backend';
|
||||
import { keyboardShortcuts, getShortcuts } from '$lib/utils';
|
||||
import { scheduleMigration } from '$lib/services/chat-index-migration.js';
|
||||
import Sidenav from '$lib/components/layout/Sidenav.svelte';
|
||||
import TopNav from '$lib/components/layout/TopNav.svelte';
|
||||
import ModelSelect from '$lib/components/layout/ModelSelect.svelte';
|
||||
import { ToastContainer, ShortcutsModal, SearchModal } from '$lib/components/shared';
|
||||
import { ToastContainer, ShortcutsModal } from '$lib/components/shared';
|
||||
import UpdateBanner from '$lib/components/shared/UpdateBanner.svelte';
|
||||
|
||||
import type { LayoutData } from './$types';
|
||||
@@ -30,9 +31,6 @@
|
||||
// Sidenav width constant
|
||||
const SIDENAV_WIDTH = 280;
|
||||
|
||||
// Search modal state
|
||||
let showSearchModal = $state(false);
|
||||
|
||||
// Shortcuts modal state
|
||||
let showShortcutsModal = $state(false);
|
||||
|
||||
@@ -66,6 +64,12 @@
|
||||
// Load conversations from IndexedDB
|
||||
loadConversations();
|
||||
|
||||
// Load projects from IndexedDB
|
||||
projectsState.load();
|
||||
|
||||
// Schedule background migration for chat indexing (runs after 5 seconds)
|
||||
scheduleMigration(5000);
|
||||
|
||||
return () => {
|
||||
uiState.destroy();
|
||||
syncManager.destroy();
|
||||
@@ -90,12 +94,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Search (Cmd/Ctrl + K) - opens global search modal
|
||||
// Search (Cmd/Ctrl + K) - navigates to search page
|
||||
keyboardShortcuts.register({
|
||||
...SHORTCUTS.SEARCH,
|
||||
preventDefault: true,
|
||||
handler: () => {
|
||||
showSearchModal = true;
|
||||
goto('/search');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -182,6 +186,3 @@
|
||||
|
||||
<!-- Keyboard shortcuts help -->
|
||||
<ShortcutsModal isOpen={showShortcutsModal} onClose={() => (showShortcutsModal = false)} />
|
||||
|
||||
<!-- Global search modal -->
|
||||
<SearchModal isOpen={showSearchModal} onClose={() => (showSearchModal = false)} />
|
||||
|
||||
@@ -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;
|
||||
@@ -57,7 +65,9 @@
|
||||
async function retrieveRagContext(query: string): Promise<string | null> {
|
||||
if (!ragEnabled || !hasKnowledgeBase) return null;
|
||||
try {
|
||||
const results = await searchSimilar(query, 3, 0.5);
|
||||
// Search global documents only (null projectId) for home page
|
||||
// Lower threshold (0.3) to catch more relevant results
|
||||
const results = await searchSimilar(query, { topK: 5, threshold: 0.3, projectId: null });
|
||||
if (results.length === 0) return null;
|
||||
return formatResultsAsContext(results);
|
||||
} catch {
|
||||
@@ -69,7 +79,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 +113,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 +253,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 +272,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 +302,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 +317,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 +369,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 +386,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();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* Displays an existing conversation with chat window
|
||||
*/
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto, replaceState } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { chatState, conversationsState, modelsState } from '$lib/stores';
|
||||
import { getConversationFull } from '$lib/storage';
|
||||
import ChatWindow from '$lib/components/chat/ChatWindow.svelte';
|
||||
@@ -20,6 +21,17 @@
|
||||
let currentConversationId = $state<string | null>(null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Extract first message from data and clear from URL
|
||||
let initialMessage = $state<string | null>(data.firstMessage);
|
||||
$effect(() => {
|
||||
// Clear firstMessage from URL to keep it clean
|
||||
if (data.firstMessage && $page.url.searchParams.has('firstMessage')) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.delete('firstMessage');
|
||||
replaceState(url, {});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Load conversation into chat state when URL changes
|
||||
*/
|
||||
@@ -135,6 +147,6 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Chat window in conversation mode -->
|
||||
<ChatWindow mode="conversation" {conversation} />
|
||||
<ChatWindow mode="conversation" {conversation} {initialMessage} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
export const load: PageLoad = async ({ params, url }) => {
|
||||
const { id } = params;
|
||||
|
||||
// Validate that ID looks like a UUID
|
||||
@@ -18,10 +18,11 @@ export const load: PageLoad = async ({ params }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: In the future, load conversation from IndexedDB here
|
||||
// For now, just return the ID and let the page component handle state
|
||||
// Extract firstMessage query param (for new chats from project page)
|
||||
const firstMessage = url.searchParams.get('firstMessage') || null;
|
||||
|
||||
return {
|
||||
conversationId: id
|
||||
conversationId: id,
|
||||
firstMessage
|
||||
};
|
||||
};
|
||||
|
||||
6
frontend/src/routes/knowledge/+page.server.ts
Normal file
6
frontend/src/routes/knowledge/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
redirect(301, '/settings?tab=knowledge');
|
||||
};
|
||||
6
frontend/src/routes/models/+page.server.ts
Normal file
6
frontend/src/routes/models/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
redirect(301, '/settings?tab=models');
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user