30 Commits

Author SHA1 Message Date
a14219c6bb Merge pull request 'chore: bump version to 0.7.0' (!9) from dev into main
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-23 15:56:59 +01:00
779a3dc452 chore: bump version to 0.7.0 2026-01-23 15:56:34 +01:00
61bf8038d0 Merge pull request 'Release v0.7.0' (!8) from dev into main 2026-01-23 15:54:50 +01:00
a65417eabe Merge pull request 'feat: add multi-backend LLM support' (!7) from feature/multi-backend-support into dev 2026-01-23 15:53:13 +01:00
291871c3b5 docs: update README for multi-backend LLM support
- Update tagline to 'local LLMs' instead of 'Ollama'
- Add LLM Backends section with Ollama, llama.cpp, LM Studio
- Update Prerequisites to list all supported backends
- Add LLM Backends to documentation table
- Update Roadmap with multi-backend as completed
- Update Non-Goals to clarify cloud providers not supported
2026-01-23 15:51:56 +01:00
a564f7ec77 fix: resolve all TypeScript errors and accessibility warnings
TypeScript error fixes:
- Fix UUID mock type in chunker.test.ts
- Remove invalid timestamp property from Message types in tests
- Fix mockFetch type in client.test.ts
- Add missing parameters property to tool definition in test

Accessibility fixes (109 → 40 warnings, remaining are CSS @apply):
- Add aria-labels to all toggle switches and icon-only buttons
- Add tabindex="-1" to all dialog elements with role="dialog"
- Add onkeydown handlers to modal backdrops for keyboard accessibility
- Fix form labels: change decorative labels to spans, use fieldset/legend for groups
- Convert fileInput variables to $state() for proper reactivity
- Fix closure captures in ThinkingBlock and HtmlPreview with $derived()
- Add role="region" to drag-and-drop zones
- Restore keyboard navigation to BranchNavigator

All 547 tests pass.
2026-01-23 15:33:25 +01:00
a80ddc0fe4 feat: add multi-backend LLM support (Ollama, llama.cpp, LM Studio)
Add unified backend abstraction layer supporting multiple LLM providers:

Backend (Go):
- New backends package with interface, registry, and adapters
- Ollama adapter wrapping existing functionality
- OpenAI-compatible adapter for llama.cpp and LM Studio
- Unified API routes under /api/v1/ai/*
- SSE to NDJSON streaming conversion for OpenAI backends
- Auto-discovery of backends on default ports

Frontend (Svelte 5):
- New backendsState store for backend management
- Unified LLM client routing through backend API
- AI Providers tab combining Backends and Models sub-tabs
- Backend-aware chat streaming (uses appropriate client)
- Model name display for non-Ollama backends in top nav
- Persist and restore last selected backend

Key features:
- Switch between backends without restart
- Conditional UI based on backend capabilities
- Models tab only visible when Ollama active
- llama.cpp/LM Studio show loaded model name
2026-01-23 15:04:49 +01:00
d6994bff48 Merge pull request 'Release v0.6.1' (!6) from dev into main
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-22 12:41:42 +01:00
daa8c87cf4 chore: bump version to 0.6.1 2026-01-22 12:41:13 +01:00
9007faab0d Merge pull request 'feat(settings): add dedicated About page' (!5) from feature/about-page into dev 2026-01-22 12:40:33 +01:00
7fe4286d34 feat(settings): add dedicated About page with version info
- Add AboutTab.svelte with app branding, version display, update checking
- Show update status and download link when new version available
- Include links to GitHub repo and issue tracker
- Display tech stack badges and license info
- Remove About section from GeneralTab (now separate tab)

Also improves development configuration:
- justfile now reads PORT, DEV_PORT, LLAMA_PORT, OLLAMA_PORT from .env
- docker-compose.dev.yml uses env var substitution
- Add dev-build and dev-rebuild recipes for Docker
- Update .env.example with all configurable variables
2026-01-22 12:39:19 +01:00
6dcaf37c7f Merge pull request 'Release v0.6.0' (!4) from dev into main
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-22 12:13:30 +01:00
29c70eca17 chore: bump version to 0.6.0 2026-01-22 12:12:06 +01:00
a31f8263e7 Merge pull request 'feat(agents): implement agents feature (v1)' (!3) from feature/agents into dev 2026-01-22 12:08:27 +01:00
9b4eeaff2a feat(agents): implement agents feature (v1)
Adds agents feature with the following capabilities:
- Agent identity: name, description
- System prompt reference from Prompt Library (promptId)
- Tool set: subset of available tools (enabledToolNames)
- Optional preferred model
- CRUD operations with IndexedDB storage (schema v7)
- Project-agent relationships (many-to-many via junction table)
- Per-chat agent selection via AgentSelector component
- Settings UI via AgentsTab in Settings page

Integration:
- Agent tools filter LLM tool calls via getToolDefinitionsForAgent()
- Agent prompt integrates with prompt resolution (priority 3)
- AgentSelector dropdown in chat UI (opens upward)

Tests:
- 22 storage layer tests
- 22 state management tests
- 7 tool integration tests
- 9 prompt resolution tests
- 14 E2E tests

Closes #7
2026-01-22 12:02:13 +01:00
e091a6c1d5 Merge pull request 'test: extend test coverage for backend and frontend' (!2) from feature/test-coverage into dev 2026-01-22 11:09:43 +01:00
b33f8ada5d Merge pull request 'feat: add .env.example and configuration documentation' (!1) from feature/config-documentation into dev 2026-01-22 11:09:34 +01:00
d81430e1aa test: extend test coverage for backend and frontend
Backend:
- Add fetcher_test.go (HTML stripping, URL fetching utilities)
- Add model_registry_test.go (parsing, size ranges, model matching)
- Add database_test.go (CRUD operations, migrations)
- Add tests for geolocation, search, tools, version handlers

Frontend unit tests (469 total):
- OllamaClient: 22 tests for API methods with mocked fetch
- Memory/RAG: tokenizer, chunker, summarizer, embeddings, vector-store
- Services: prompt-resolution, conversation-summary
- Components: Skeleton, BranchNavigator, ConfirmDialog, ThinkingBlock
- Utils: export, import, file-processor, keyboard
- Tools: builtin math parser (44 tests)

E2E tests (28 total):
- Set up Playwright with Chromium
- App loading, sidebar navigation, settings page
- Chat interface, responsive design, accessibility
- Import dialog, project modal interactions

Config changes:
- Add browser conditions to vitest.config.ts for Svelte 5 components
- Add playwright.config.ts for E2E testing
- Add test:e2e scripts to package.json
- Update .gitignore to exclude test artifacts

Closes #8
2026-01-22 11:05:49 +01:00
2c2744fc27 feat: add .env.example and fix hardcoded Ollama URL
- Add .env.example with all documented environment variables
- Fix conversation-summary.ts to use proxy instead of hardcoded localhost

Closes #9
2026-01-22 09:21:49 +01:00
34f2f1bad8 docs: update CONTRIBUTING with branching strategy
- Add branching workflow: main -> dev -> feature/*
- Document that PRs should target dev branch
- Add release workflow documentation
2026-01-22 09:06:39 +01:00
0e7a3ccb7f chore: add justfile for development commands
- Backend runs on port 9090 (matches vite proxy default)
- Adds health check command for all services
- Includes llama.cpp server commands
- Adds test and build commands
2026-01-22 09:06:31 +01:00
d48cf7ce72 feat: add sync status indicator and warning banner
- Add SyncStatusIndicator component showing connection status in TopNav
- Add SyncWarningBanner that appears after 30s of backend disconnection
- Green dot when synced, amber pulsing when syncing, red when error/offline
- Warning banner is dismissible but reappears on next failure

Closes #11
2026-01-22 09:06:23 +01:00
Chris
c97bd572f2 Merge pull request #10 from half-measures/fear/Adding-tests
feat: Adding tests to chat.go
2026-01-22 08:47:41 +01:00
=
d8a48ba0af feat: Adding tests to chat.go 2026-01-20 14:26:24 -08:00
f98dea4826 chore: bump version to 0.5.2
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-07 22:20:44 +01:00
792cc19abe fix: improve sidebar hover icon visibility 2026-01-07 22:20:09 +01:00
27c9038835 docs: update README with new features (Projects, RAG, Search) 2026-01-07 22:15:52 +01:00
62c45492fa fix: persist conversation pin/archive state to IndexedDB
- Fix pin icons in ConversationItem to use bookmark style matching TopNav
- Make pin() and archive() methods async with IndexedDB persistence
- Use optimistic updates with rollback on failure
- Queue changes for backend sync via markForSync()
2026-01-07 22:04:01 +01:00
196d28ca25 chore: bump version to 0.5.1
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-07 20:51:56 +01:00
f3ba4c8876 fix: project deletion and replace confirm() with ConfirmDialog
Bug fixes:
- Fix project delete failing by adding db.chunks to transaction

UX improvements:
- Replace browser confirm() dialogs with styled ConfirmDialog component
- Affected: ProjectModal, ToolsTab, KnowledgeTab, PromptsTab, project page
2026-01-07 20:51:33 +01:00
118 changed files with 16054 additions and 366 deletions

40
.env.example Normal file
View File

@@ -0,0 +1,40 @@
# ===========================================
# Vessel Configuration
# ===========================================
# Copy this file to .env and adjust values as needed.
# All variables have sensible defaults - only set what you need to change.
# ----- Backend -----
# Server port (default: 9090 for local dev, matches vite proxy)
PORT=9090
# SQLite database path (relative to backend working directory)
DB_PATH=./data/vessel.db
# Ollama API endpoint
OLLAMA_URL=http://localhost:11434
# GitHub repo for version checking (format: owner/repo)
GITHUB_REPO=VikingOwl91/vessel
# ----- Frontend -----
# Ollama API endpoint (for frontend proxy)
OLLAMA_API_URL=http://localhost:11434
# Backend API endpoint
BACKEND_URL=http://localhost:9090
# Development server port
DEV_PORT=7842
# ----- llama.cpp -----
# llama.cpp server port (used by `just llama-server`)
LLAMA_PORT=8081
# ----- Additional Ports (for health checks) -----
# Ollama port (extracted from OLLAMA_URL for health checks)
OLLAMA_PORT=11434
# ----- Models -----
# Directory for GGUF model files
VESSEL_MODELS_DIR=~/.vessel/models

4
.gitignore vendored
View File

@@ -45,3 +45,7 @@ backend/data-dev/
# Generated files
frontend/static/pdf.worker.min.mjs
# Test artifacts
frontend/playwright-report/
frontend/test-results/

View File

@@ -2,11 +2,63 @@
Thanks for your interest in Vessel.
- Issues and pull requests are handled on GitHub:
https://github.com/VikingOwl91/vessel
### Where to Contribute
- Keep changes focused and small.
- UI and UX improvements are welcome.
- Vessel intentionally avoids becoming a platform.
- **Issues**: Open on GitHub at https://github.com/VikingOwl91/vessel
- **Pull Requests**: Submit via GitHub (for external contributors) or Gitea (for maintainers)
If youre unsure whether something fits, open an issue first.
### Branching Strategy
```
main (protected - releases only)
└── dev (default development branch)
└── feature/your-feature
└── fix/bug-description
```
- **main**: Production releases only. No direct pushes allowed.
- **dev**: Active development. All changes merge here first.
- **feature/***: New features, branch from `dev`
- **fix/***: Bug fixes, branch from `dev`
### Workflow
1. **Fork** the repository (external contributors)
2. **Clone** and switch to dev:
```bash
git clone https://github.com/VikingOwl91/vessel.git
cd vessel
git checkout dev
```
3. **Create a feature branch**:
```bash
git checkout -b feature/your-feature
```
4. **Make changes** with clear, focused commits
5. **Test** your changes
6. **Push** and create a PR targeting `dev`:
```bash
git push -u origin feature/your-feature
```
7. Open a PR from your branch to `dev`
### Commit Messages
Follow conventional commits:
- `feat:` New features
- `fix:` Bug fixes
- `docs:` Documentation changes
- `refactor:` Code refactoring
- `test:` Adding tests
- `chore:` Maintenance tasks
### Guidelines
- Keep changes focused and small
- UI and UX improvements are welcome
- Vessel intentionally avoids becoming a platform
- If unsure whether something fits, open an issue first
### Development Setup
See the [Development Wiki](https://github.com/VikingOwl91/vessel/wiki/Development) for detailed setup instructions.

View File

@@ -5,7 +5,7 @@
<h1 align="center">Vessel</h1>
<p align="center">
<strong>A modern, feature-rich web interface for Ollama</strong>
<strong>A modern, feature-rich web interface for local LLMs</strong>
</p>
<p align="center">
@@ -28,33 +28,55 @@
**Vessel** is intentionally focused on:
- A clean, local-first UI for **Ollama**
- A clean, local-first UI for **local LLMs**
- **Multiple backends**: Ollama, llama.cpp, LM Studio
- Minimal configuration
- Low visual and cognitive overhead
- Doing a small set of things well
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.
If you want a **small, focused UI for local LLM usage** → Vessel is built for that.
---
## Features
### Chat
- Real-time streaming responses
- Message editing with branch navigation
- Real-time streaming responses with token metrics
- **Message branching** — edit any message to create alternative conversation paths
- Markdown rendering with syntax highlighting
- **Thinking mode** — native support for reasoning models (DeepSeek-R1, etc.)
- Dark/Light themes
### Projects & Organization
- **Projects** — group related conversations together
- Pin and archive conversations
- Smart title generation from conversation content
- **Global search** — semantic, title, and content search across all chats
### Knowledge Base (RAG)
- Upload documents (text, markdown, PDF) to build a knowledge base
- **Semantic search** using embeddings for context-aware retrieval
- Project-specific or global knowledge bases
- Automatic context injection into conversations
### Tools
- **5 built-in tools**: web search, URL fetching, calculator, location, time
- **Custom tools**: Create your own in JavaScript, Python, or HTTP
- Agentic tool calling with chain-of-thought reasoning
- Test tools before saving with the built-in testing panel
### Models
### LLM Backends
- **Ollama** — Full model management, pull/delete/create custom models
- **llama.cpp** — High-performance inference with GGUF models
- **LM Studio** — Desktop app integration
- Switch backends without restart, auto-detection of available backends
### Models (Ollama)
- Browse and pull models from ollama.com
- Create custom models with embedded system prompts
- Track model updates
- **Per-model parameters** — customize temperature, context size, top_k/top_p
- Track model updates and capability detection (vision, tools, code)
### Prompts
- Save and organize system prompts
@@ -97,7 +119,10 @@ If you want a **small, focused UI for local Ollama usage** → Vessel is built f
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- [Ollama](https://ollama.com/download) running locally
- An LLM backend (at least one):
- [Ollama](https://ollama.com/download) (recommended)
- [llama.cpp](https://github.com/ggerganov/llama.cpp)
- [LM Studio](https://lmstudio.ai/)
### Configure Ollama
@@ -145,6 +170,10 @@ Full documentation is available on the **[GitHub Wiki](https://github.com/Viking
| Guide | Description |
|-------|-------------|
| [Getting Started](https://github.com/VikingOwl91/vessel/wiki/Getting-Started) | Installation and configuration |
| [LLM Backends](https://github.com/VikingOwl91/vessel/wiki/LLM-Backends) | Configure Ollama, llama.cpp, or LM Studio |
| [Projects](https://github.com/VikingOwl91/vessel/wiki/Projects) | Organize conversations into projects |
| [Knowledge Base](https://github.com/VikingOwl91/vessel/wiki/Knowledge-Base) | RAG with document upload and semantic search |
| [Search](https://github.com/VikingOwl91/vessel/wiki/Search) | Semantic and content search across chats |
| [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 |
@@ -160,10 +189,16 @@ Full documentation is available on the **[GitHub Wiki](https://github.com/Viking
Vessel prioritizes **usability and simplicity** over feature breadth.
**Completed:**
- [x] Multi-backend support (Ollama, llama.cpp, LM Studio)
- [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
- [x] Projects for conversation organization
- [x] Knowledge base with RAG (semantic retrieval)
- [x] Global search (semantic, title, content)
- [x] Thinking mode for reasoning models
- [x] Message branching and conversation trees
**Planned:**
- [ ] Keyboard-first workflows
@@ -174,7 +209,7 @@ Vessel prioritizes **usability and simplicity** over feature breadth.
- Multi-user systems
- Cloud sync
- Plugin ecosystems
- Support for every LLM runtime
- Cloud/API-based LLM providers (OpenAI, Anthropic, etc.)
> *Do one thing well. Keep the UI out of the way.*
@@ -200,5 +235,5 @@ Contributions are welcome!
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>
Made with <a href="https://svelte.dev">Svelte</a> • Supports <a href="https://ollama.com">Ollama</a>, <a href="https://github.com/ggerganov/llama.cpp">llama.cpp</a>, and <a href="https://lmstudio.ai/">LM Studio</a>
</p>

View File

@@ -14,11 +14,14 @@ import (
"github.com/gin-gonic/gin"
"vessel-backend/internal/api"
"vessel-backend/internal/backends"
"vessel-backend/internal/backends/ollama"
"vessel-backend/internal/backends/openai"
"vessel-backend/internal/database"
)
// Version is set at build time via -ldflags, or defaults to dev
var Version = "0.5.0"
var Version = "0.7.0"
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
@@ -29,9 +32,11 @@ func getEnvOrDefault(key, defaultValue string) string {
func main() {
var (
port = flag.String("port", getEnvOrDefault("PORT", "8080"), "Server port")
dbPath = flag.String("db", getEnvOrDefault("DB_PATH", "./data/vessel.db"), "Database file path")
ollamaURL = flag.String("ollama-url", getEnvOrDefault("OLLAMA_URL", "http://localhost:11434"), "Ollama API URL")
port = flag.String("port", getEnvOrDefault("PORT", "8080"), "Server port")
dbPath = flag.String("db", getEnvOrDefault("DB_PATH", "./data/vessel.db"), "Database file path")
ollamaURL = flag.String("ollama-url", getEnvOrDefault("OLLAMA_URL", "http://localhost:11434"), "Ollama API URL")
llamacppURL = flag.String("llamacpp-url", getEnvOrDefault("LLAMACPP_URL", "http://localhost:8081"), "llama.cpp server URL")
lmstudioURL = flag.String("lmstudio-url", getEnvOrDefault("LMSTUDIO_URL", "http://localhost:1234"), "LM Studio server URL")
)
flag.Parse()
@@ -47,6 +52,52 @@ func main() {
log.Fatalf("Failed to run migrations: %v", err)
}
// Initialize backend registry
registry := backends.NewRegistry()
// Register Ollama backend
ollamaAdapter, err := ollama.NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: *ollamaURL,
})
if err != nil {
log.Printf("Warning: Failed to create Ollama adapter: %v", err)
} else {
if err := registry.Register(ollamaAdapter); err != nil {
log.Printf("Warning: Failed to register Ollama backend: %v", err)
}
}
// Register llama.cpp backend (if URL is configured)
if *llamacppURL != "" {
llamacppAdapter, err := openai.NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: *llamacppURL,
})
if err != nil {
log.Printf("Warning: Failed to create llama.cpp adapter: %v", err)
} else {
if err := registry.Register(llamacppAdapter); err != nil {
log.Printf("Warning: Failed to register llama.cpp backend: %v", err)
}
}
}
// Register LM Studio backend (if URL is configured)
if *lmstudioURL != "" {
lmstudioAdapter, err := openai.NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLMStudio,
BaseURL: *lmstudioURL,
})
if err != nil {
log.Printf("Warning: Failed to create LM Studio adapter: %v", err)
} else {
if err := registry.Register(lmstudioAdapter); err != nil {
log.Printf("Warning: Failed to register LM Studio backend: %v", err)
}
}
}
// Setup Gin router
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -64,7 +115,7 @@ func main() {
}))
// Register routes
api.SetupRoutes(r, db, *ollamaURL, Version)
api.SetupRoutes(r, db, *ollamaURL, Version, registry)
// Create server
srv := &http.Server{
@@ -79,8 +130,12 @@ func main() {
// Graceful shutdown handling
go func() {
log.Printf("Server starting on port %s", *port)
log.Printf("Ollama URL: %s (using official Go client)", *ollamaURL)
log.Printf("Database: %s", *dbPath)
log.Printf("Backends configured:")
log.Printf(" - Ollama: %s", *ollamaURL)
log.Printf(" - llama.cpp: %s", *llamacppURL)
log.Printf(" - LM Studio: %s", *lmstudioURL)
log.Printf("Active backend: %s", registry.ActiveType().String())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}

View File

@@ -0,0 +1,275 @@
package api
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"vessel-backend/internal/backends"
)
// AIHandlers provides HTTP handlers for the unified AI API
type AIHandlers struct {
registry *backends.Registry
}
// NewAIHandlers creates a new AIHandlers instance
func NewAIHandlers(registry *backends.Registry) *AIHandlers {
return &AIHandlers{
registry: registry,
}
}
// ListBackendsHandler returns information about all configured backends
func (h *AIHandlers) ListBackendsHandler() gin.HandlerFunc {
return func(c *gin.Context) {
infos := h.registry.AllInfo(c.Request.Context())
c.JSON(http.StatusOK, gin.H{
"backends": infos,
"active": h.registry.ActiveType().String(),
})
}
}
// DiscoverBackendsHandler probes for available backends
func (h *AIHandlers) DiscoverBackendsHandler() gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Endpoints []backends.DiscoveryEndpoint `json:"endpoints"`
}
if err := c.ShouldBindJSON(&req); err != nil {
// Use default endpoints if none provided
req.Endpoints = backends.DefaultDiscoveryEndpoints()
}
if len(req.Endpoints) == 0 {
req.Endpoints = backends.DefaultDiscoveryEndpoints()
}
results := h.registry.Discover(c.Request.Context(), req.Endpoints)
c.JSON(http.StatusOK, gin.H{
"results": results,
})
}
}
// SetActiveHandler sets the active backend
func (h *AIHandlers) SetActiveHandler() gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Type string `json:"type" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"})
return
}
backendType, err := backends.ParseBackendType(req.Type)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.registry.SetActive(backendType); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"active": backendType.String(),
})
}
}
// HealthCheckHandler checks the health of a specific backend
func (h *AIHandlers) HealthCheckHandler() gin.HandlerFunc {
return func(c *gin.Context) {
typeParam := c.Param("type")
backendType, err := backends.ParseBackendType(typeParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
backend, ok := h.registry.Get(backendType)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "backend not registered"})
return
}
if err := backend.HealthCheck(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unhealthy",
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
})
}
}
// ListModelsHandler returns models from the active backend
func (h *AIHandlers) ListModelsHandler() gin.HandlerFunc {
return func(c *gin.Context) {
active := h.registry.Active()
if active == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "no active backend"})
return
}
models, err := active.ListModels(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"models": models,
"backend": active.Type().String(),
})
}
}
// ChatHandler handles chat requests through the active backend
func (h *AIHandlers) ChatHandler() gin.HandlerFunc {
return func(c *gin.Context) {
active := h.registry.Active()
if active == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "no active backend"})
return
}
var req backends.ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
return
}
if err := req.Validate(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if streaming is requested
streaming := req.Stream != nil && *req.Stream
if streaming {
h.handleStreamingChat(c, active, &req)
} else {
h.handleNonStreamingChat(c, active, &req)
}
}
}
// handleNonStreamingChat handles non-streaming chat requests
func (h *AIHandlers) handleNonStreamingChat(c *gin.Context, backend backends.LLMBackend, req *backends.ChatRequest) {
resp, err := backend.Chat(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// handleStreamingChat handles streaming chat requests
func (h *AIHandlers) handleStreamingChat(c *gin.Context, backend backends.LLMBackend, req *backends.ChatRequest) {
// Set headers for NDJSON streaming
c.Header("Content-Type", "application/x-ndjson")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Transfer-Encoding", "chunked")
ctx := c.Request.Context()
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
return
}
chunkCh, err := backend.StreamChat(ctx, req)
if err != nil {
errResp := gin.H{"error": err.Error()}
data, _ := json.Marshal(errResp)
c.Writer.Write(append(data, '\n'))
flusher.Flush()
return
}
for chunk := range chunkCh {
select {
case <-ctx.Done():
return
default:
}
data, err := json.Marshal(chunk)
if err != nil {
continue
}
_, err = c.Writer.Write(append(data, '\n'))
if err != nil {
return
}
flusher.Flush()
}
}
// RegisterBackendHandler registers a new backend
func (h *AIHandlers) RegisterBackendHandler() gin.HandlerFunc {
return func(c *gin.Context) {
var req backends.BackendConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
return
}
if err := req.Validate(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create adapter based on type
var backend backends.LLMBackend
var err error
switch req.Type {
case backends.BackendTypeOllama:
// Would import ollama adapter
c.JSON(http.StatusNotImplemented, gin.H{"error": "use /api/v1/ai/backends/discover to register backends"})
return
case backends.BackendTypeLlamaCpp, backends.BackendTypeLMStudio:
// Would import openai adapter
c.JSON(http.StatusNotImplemented, gin.H{"error": "use /api/v1/ai/backends/discover to register backends"})
return
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown backend type"})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.registry.Register(backend); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"type": req.Type.String(),
"baseUrl": req.BaseURL,
})
}
}

View File

@@ -0,0 +1,354 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"vessel-backend/internal/backends"
)
func setupAITestRouter(registry *backends.Registry) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
handlers := NewAIHandlers(registry)
ai := r.Group("/api/v1/ai")
{
ai.GET("/backends", handlers.ListBackendsHandler())
ai.POST("/backends/discover", handlers.DiscoverBackendsHandler())
ai.POST("/backends/active", handlers.SetActiveHandler())
ai.GET("/backends/:type/health", handlers.HealthCheckHandler())
ai.POST("/chat", handlers.ChatHandler())
ai.GET("/models", handlers.ListModelsHandler())
}
return r
}
func TestAIHandlers_ListBackends(t *testing.T) {
registry := backends.NewRegistry()
mock := &mockAIBackend{
backendType: backends.BackendTypeOllama,
config: backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
},
info: backends.BackendInfo{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
Status: backends.BackendStatusConnected,
Capabilities: backends.OllamaCapabilities(),
Version: "0.3.0",
},
}
registry.Register(mock)
registry.SetActive(backends.BackendTypeOllama)
router := setupAITestRouter(registry)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/ai/backends", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("ListBackends() status = %d, want %d", w.Code, http.StatusOK)
}
var resp struct {
Backends []backends.BackendInfo `json:"backends"`
Active string `json:"active"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if len(resp.Backends) != 1 {
t.Errorf("ListBackends() returned %d backends, want 1", len(resp.Backends))
}
if resp.Active != "ollama" {
t.Errorf("ListBackends() active = %q, want %q", resp.Active, "ollama")
}
}
func TestAIHandlers_SetActive(t *testing.T) {
registry := backends.NewRegistry()
mock := &mockAIBackend{
backendType: backends.BackendTypeOllama,
config: backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
},
}
registry.Register(mock)
router := setupAITestRouter(registry)
t.Run("set valid backend active", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"type": "ollama"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/ai/backends/active", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("SetActive() status = %d, want %d", w.Code, http.StatusOK)
}
if registry.ActiveType() != backends.BackendTypeOllama {
t.Errorf("Active backend = %v, want %v", registry.ActiveType(), backends.BackendTypeOllama)
}
})
t.Run("set invalid backend active", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"type": "llamacpp"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/ai/backends/active", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("SetActive() status = %d, want %d", w.Code, http.StatusBadRequest)
}
})
}
func TestAIHandlers_HealthCheck(t *testing.T) {
registry := backends.NewRegistry()
mock := &mockAIBackend{
backendType: backends.BackendTypeOllama,
config: backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
},
healthErr: nil,
}
registry.Register(mock)
router := setupAITestRouter(registry)
t.Run("healthy backend", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/ai/backends/ollama/health", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("HealthCheck() status = %d, want %d", w.Code, http.StatusOK)
}
})
t.Run("non-existent backend", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/ai/backends/llamacpp/health", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("HealthCheck() status = %d, want %d", w.Code, http.StatusNotFound)
}
})
}
func TestAIHandlers_ListModels(t *testing.T) {
registry := backends.NewRegistry()
mock := &mockAIBackend{
backendType: backends.BackendTypeOllama,
config: backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
},
models: []backends.Model{
{ID: "llama3.2:8b", Name: "llama3.2:8b", Family: "llama"},
{ID: "mistral:7b", Name: "mistral:7b", Family: "mistral"},
},
}
registry.Register(mock)
registry.SetActive(backends.BackendTypeOllama)
router := setupAITestRouter(registry)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/ai/models", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("ListModels() status = %d, want %d", w.Code, http.StatusOK)
}
var resp struct {
Models []backends.Model `json:"models"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if len(resp.Models) != 2 {
t.Errorf("ListModels() returned %d models, want 2", len(resp.Models))
}
}
func TestAIHandlers_ListModels_NoActiveBackend(t *testing.T) {
registry := backends.NewRegistry()
router := setupAITestRouter(registry)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/ai/models", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("ListModels() status = %d, want %d", w.Code, http.StatusServiceUnavailable)
}
}
func TestAIHandlers_Chat(t *testing.T) {
registry := backends.NewRegistry()
mock := &mockAIBackend{
backendType: backends.BackendTypeOllama,
config: backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
},
chatResponse: &backends.ChatChunk{
Model: "llama3.2:8b",
Message: &backends.ChatMessage{
Role: "assistant",
Content: "Hello! How can I help?",
},
Done: true,
},
}
registry.Register(mock)
registry.SetActive(backends.BackendTypeOllama)
router := setupAITestRouter(registry)
t.Run("non-streaming chat", func(t *testing.T) {
chatReq := backends.ChatRequest{
Model: "llama3.2:8b",
Messages: []backends.ChatMessage{
{Role: "user", Content: "Hello"},
},
}
body, _ := json.Marshal(chatReq)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/ai/chat", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Chat() status = %d, want %d, body: %s", w.Code, http.StatusOK, w.Body.String())
}
var resp backends.ChatChunk
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if !resp.Done {
t.Error("Chat() response.Done = false, want true")
}
if resp.Message == nil || resp.Message.Content != "Hello! How can I help?" {
t.Errorf("Chat() unexpected response: %+v", resp)
}
})
}
func TestAIHandlers_Chat_InvalidRequest(t *testing.T) {
registry := backends.NewRegistry()
mock := &mockAIBackend{
backendType: backends.BackendTypeOllama,
}
registry.Register(mock)
registry.SetActive(backends.BackendTypeOllama)
router := setupAITestRouter(registry)
// Missing model
chatReq := map[string]interface{}{
"messages": []map[string]string{
{"role": "user", "content": "Hello"},
},
}
body, _ := json.Marshal(chatReq)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/ai/chat", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Chat() status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
// mockAIBackend implements backends.LLMBackend for testing
type mockAIBackend struct {
backendType backends.BackendType
config backends.BackendConfig
info backends.BackendInfo
healthErr error
models []backends.Model
chatResponse *backends.ChatChunk
}
func (m *mockAIBackend) Type() backends.BackendType {
return m.backendType
}
func (m *mockAIBackend) Config() backends.BackendConfig {
return m.config
}
func (m *mockAIBackend) HealthCheck(ctx context.Context) error {
return m.healthErr
}
func (m *mockAIBackend) ListModels(ctx context.Context) ([]backends.Model, error) {
return m.models, nil
}
func (m *mockAIBackend) StreamChat(ctx context.Context, req *backends.ChatRequest) (<-chan backends.ChatChunk, error) {
ch := make(chan backends.ChatChunk, 1)
if m.chatResponse != nil {
ch <- *m.chatResponse
}
close(ch)
return ch, nil
}
func (m *mockAIBackend) Chat(ctx context.Context, req *backends.ChatRequest) (*backends.ChatChunk, error) {
if m.chatResponse != nil {
return m.chatResponse, nil
}
return &backends.ChatChunk{Done: true}, nil
}
func (m *mockAIBackend) Capabilities() backends.BackendCapabilities {
return backends.OllamaCapabilities()
}
func (m *mockAIBackend) Info(ctx context.Context) backends.BackendInfo {
if m.info.Type != "" {
return m.info
}
return backends.BackendInfo{
Type: m.backendType,
BaseURL: m.config.BaseURL,
Status: backends.BackendStatusConnected,
Capabilities: m.Capabilities(),
}
}

View File

@@ -0,0 +1,277 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"vessel-backend/internal/database"
"vessel-backend/internal/models"
"github.com/gin-gonic/gin"
_ "modernc.org/sqlite"
)
func setupTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
if err := database.RunMigrations(db); err != nil {
t.Fatalf("failed to run migrations: %v", err)
}
return db
}
func setupRouter(db *sql.DB) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(gin.Recovery())
r.GET("/chats", ListChatsHandler(db))
r.GET("/chats/grouped", ListGroupedChatsHandler(db))
r.GET("/chats/:id", GetChatHandler(db))
r.POST("/chats", CreateChatHandler(db))
r.PATCH("/chats/:id", UpdateChatHandler(db))
r.DELETE("/chats/:id", DeleteChatHandler(db))
r.POST("/chats/:id/messages", CreateMessageHandler(db))
return r
}
func TestListChatsHandler(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
router := setupRouter(db)
// Seed some data
chat1 := &models.Chat{ID: "chat1", Title: "Chat 1", Model: "gpt-4", Archived: false}
chat2 := &models.Chat{ID: "chat2", Title: "Chat 2", Model: "gpt-4", Archived: true}
models.CreateChat(db, chat1)
models.CreateChat(db, chat2)
t.Run("List non-archived chats", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/chats", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var response map[string][]models.Chat
json.Unmarshal(w.Body.Bytes(), &response)
if len(response["chats"]) != 1 {
t.Errorf("expected 1 chat, got %d", len(response["chats"]))
}
})
t.Run("List including archived chats", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/chats?include_archived=true", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var response map[string][]models.Chat
json.Unmarshal(w.Body.Bytes(), &response)
if len(response["chats"]) != 2 {
t.Errorf("expected 2 chats, got %d", len(response["chats"]))
}
})
}
func TestListGroupedChatsHandler(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
router := setupRouter(db)
// Seed some data
models.CreateChat(db, &models.Chat{ID: "chat1", Title: "Apple Chat", Model: "gpt-4"})
models.CreateChat(db, &models.Chat{ID: "chat2", Title: "Banana Chat", Model: "gpt-4"})
t.Run("Search chats", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/chats/grouped?search=Apple", nil)
router.ServeHTTP(w, req)
var resp models.GroupedChatsResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Total != 1 {
t.Errorf("expected 1 chat, got %d", resp.Total)
}
})
t.Run("Pagination", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/chats/grouped?limit=1&offset=0", nil)
router.ServeHTTP(w, req)
var resp models.GroupedChatsResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Groups) != 1 || len(resp.Groups[0].Chats) != 1 {
t.Errorf("expected 1 chat in response, got %d", len(resp.Groups[0].Chats))
}
})
}
func TestGetChatHandler(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
router := setupRouter(db)
chat := &models.Chat{ID: "test-chat", Title: "Test Chat", Model: "gpt-4"}
models.CreateChat(db, chat)
t.Run("Get existing chat", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/chats/test-chat", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
})
t.Run("Get non-existent chat", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/chats/invalid", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
})
}
func TestCreateChatHandler(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
router := setupRouter(db)
body := CreateChatRequest{Title: "New Chat Title", Model: "gpt-4"}
jsonBody, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/chats", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", w.Code)
}
var chat models.Chat
json.Unmarshal(w.Body.Bytes(), &chat)
if chat.Title != "New Chat Title" {
t.Errorf("expected title 'New Chat Title', got '%s'", chat.Title)
}
}
func TestUpdateChatHandler(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
router := setupRouter(db)
chat := &models.Chat{ID: "test-chat", Title: "Old Title", Model: "gpt-4"}
models.CreateChat(db, chat)
newTitle := "Updated Title"
body := UpdateChatRequest{Title: &newTitle}
jsonBody, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", "/chats/test-chat", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var updatedChat models.Chat
json.Unmarshal(w.Body.Bytes(), &updatedChat)
if updatedChat.Title != "Updated Title" {
t.Errorf("expected title 'Updated Title', got '%s'", updatedChat.Title)
}
}
func TestDeleteChatHandler(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
router := setupRouter(db)
chat := &models.Chat{ID: "test-chat", Title: "To Delete", Model: "gpt-4"}
models.CreateChat(db, chat)
t.Run("Delete existing chat", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/chats/test-chat", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
})
t.Run("Delete non-existent chat", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/chats/invalid", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
})
}
func TestCreateMessageHandler(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
router := setupRouter(db)
chat := &models.Chat{ID: "test-chat", Title: "Message Test", Model: "gpt-4"}
models.CreateChat(db, chat)
t.Run("Create valid message", func(t *testing.T) {
body := CreateMessageRequest{
Role: "user",
Content: "Hello world",
}
jsonBody, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/chats/test-chat/messages", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", w.Code)
fmt.Println(w.Body.String())
}
})
t.Run("Create message with invalid role", func(t *testing.T) {
body := CreateMessageRequest{
Role: "invalid",
Content: "Hello world",
}
jsonBody, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/chats/test-chat/messages", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
}

View File

@@ -0,0 +1,196 @@
package api
import (
"testing"
)
func TestDefaultFetchOptions(t *testing.T) {
opts := DefaultFetchOptions()
if opts.MaxLength != 500000 {
t.Errorf("expected MaxLength 500000, got %d", opts.MaxLength)
}
if opts.Timeout.Seconds() != 30 {
t.Errorf("expected Timeout 30s, got %v", opts.Timeout)
}
if opts.UserAgent == "" {
t.Error("expected non-empty UserAgent")
}
if opts.Headers == nil {
t.Error("expected Headers to be initialized")
}
if !opts.FollowRedirects {
t.Error("expected FollowRedirects to be true")
}
if opts.WaitTime.Seconds() != 2 {
t.Errorf("expected WaitTime 2s, got %v", opts.WaitTime)
}
}
func TestStripHTMLTags(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "removes simple tags",
input: "<p>Hello World</p>",
expected: "Hello World",
},
{
name: "removes nested tags",
input: "<div><span>Nested</span> content</div>",
expected: "Nested content",
},
{
name: "removes script tags with content",
input: "<p>Before</p><script>alert('xss')</script><p>After</p>",
expected: "Before After",
},
{
name: "removes style tags with content",
input: "<p>Text</p><style>.foo{color:red}</style><p>More</p>",
expected: "Text More",
},
{
name: "collapses whitespace",
input: "<p>Lots of spaces</p>",
expected: "Lots of spaces",
},
{
name: "handles empty input",
input: "",
expected: "",
},
{
name: "handles plain text",
input: "No HTML here",
expected: "No HTML here",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := stripHTMLTags(tt.input)
if result != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, result)
}
})
}
}
func TestIsJSRenderedPage(t *testing.T) {
f := &Fetcher{}
tests := []struct {
name string
content string
expected bool
}{
{
name: "short content indicates JS rendering",
content: "<html><body><div id=\"app\"></div></body></html>",
expected: true,
},
{
name: "React root div with minimal content",
content: "<html><body><div id=\"root\"></div><script>window.__INITIAL_STATE__={}</script></body></html>",
expected: true,
},
{
name: "Next.js pattern",
content: "<html><body><div id=\"__next\"></div></body></html>",
expected: true,
},
{
name: "Nuxt.js pattern",
content: "<html><body><div id=\"__nuxt\"></div></body></html>",
expected: true,
},
{
name: "noscript indicator",
content: "<html><body><noscript>Enable JS</noscript><div></div></body></html>",
expected: true,
},
{
name: "substantial content is not JS-rendered",
content: generateLongContent(2000),
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := f.isJSRenderedPage(tt.content)
if result != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, result)
}
})
}
}
// generateLongContent creates content of specified length
func generateLongContent(length int) string {
base := "<html><body><article>"
content := ""
word := "word "
for len(content) < length {
content += word
}
return base + content + "</article></body></html>"
}
func TestFetchMethod_String(t *testing.T) {
tests := []struct {
method FetchMethod
expected string
}{
{FetchMethodCurl, "curl"},
{FetchMethodWget, "wget"},
{FetchMethodChrome, "chrome"},
{FetchMethodNative, "native"},
}
for _, tt := range tests {
t.Run(string(tt.method), func(t *testing.T) {
if string(tt.method) != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, string(tt.method))
}
})
}
}
func TestFetchResult_Fields(t *testing.T) {
result := FetchResult{
Content: "test content",
ContentType: "text/html",
FinalURL: "https://example.com",
StatusCode: 200,
Method: FetchMethodNative,
Truncated: true,
OriginalSize: 1000000,
}
if result.Content != "test content" {
t.Errorf("Content mismatch")
}
if result.ContentType != "text/html" {
t.Errorf("ContentType mismatch")
}
if result.FinalURL != "https://example.com" {
t.Errorf("FinalURL mismatch")
}
if result.StatusCode != 200 {
t.Errorf("StatusCode mismatch")
}
if result.Method != FetchMethodNative {
t.Errorf("Method mismatch")
}
if !result.Truncated {
t.Errorf("Truncated should be true")
}
if result.OriginalSize != 1000000 {
t.Errorf("OriginalSize mismatch")
}
}

View File

@@ -0,0 +1,133 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
name string
ip string
expected bool
}{
// Loopback addresses
{"IPv4 loopback", "127.0.0.1", true},
{"IPv6 loopback", "::1", true},
// Private IPv4 ranges (RFC 1918)
{"10.x.x.x range", "10.0.0.1", true},
{"10.x.x.x high", "10.255.255.255", true},
{"172.16.x.x range", "172.16.0.1", true},
{"172.31.x.x range", "172.31.255.255", true},
{"192.168.x.x range", "192.168.0.1", true},
{"192.168.x.x high", "192.168.255.255", true},
// Public IPv4 addresses
{"Google DNS", "8.8.8.8", false},
{"Cloudflare DNS", "1.1.1.1", false},
{"Random public IP", "203.0.113.50", false},
// Edge cases - not in private ranges
{"172.15.x.x not private", "172.15.0.1", false},
{"172.32.x.x not private", "172.32.0.1", false},
{"192.167.x.x not private", "192.167.0.1", false},
// IPv6 private (fc00::/7)
{"IPv6 private fc", "fc00::1", true},
{"IPv6 private fd", "fd00::1", true},
// IPv6 public
{"IPv6 public", "2001:4860:4860::8888", false},
// Invalid inputs
{"invalid IP", "not-an-ip", false},
{"empty string", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isPrivateIP(tt.ip)
if result != tt.expected {
t.Errorf("isPrivateIP(%q) = %v, want %v", tt.ip, result, tt.expected)
}
})
}
}
func TestGetClientIP(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
headers map[string]string
remoteAddr string
expected string
}{
{
name: "X-Forwarded-For single IP",
headers: map[string]string{"X-Forwarded-For": "203.0.113.50"},
remoteAddr: "127.0.0.1:8080",
expected: "203.0.113.50",
},
{
name: "X-Forwarded-For multiple IPs",
headers: map[string]string{"X-Forwarded-For": "203.0.113.50, 70.41.3.18, 150.172.238.178"},
remoteAddr: "127.0.0.1:8080",
expected: "203.0.113.50",
},
{
name: "X-Real-IP header",
headers: map[string]string{"X-Real-IP": "198.51.100.178"},
remoteAddr: "127.0.0.1:8080",
expected: "198.51.100.178",
},
{
name: "X-Forwarded-For takes precedence over X-Real-IP",
headers: map[string]string{"X-Forwarded-For": "203.0.113.50", "X-Real-IP": "198.51.100.178"},
remoteAddr: "127.0.0.1:8080",
expected: "203.0.113.50",
},
{
name: "fallback to RemoteAddr",
headers: map[string]string{},
remoteAddr: "192.168.1.100:54321",
expected: "192.168.1.100",
},
{
name: "X-Forwarded-For with whitespace",
headers: map[string]string{"X-Forwarded-For": " 203.0.113.50 "},
remoteAddr: "127.0.0.1:8080",
expected: "203.0.113.50",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := gin.New()
var capturedIP string
router.GET("/test", func(c *gin.Context) {
capturedIP = getClientIP(c)
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
req.RemoteAddr = tt.remoteAddr
for key, value := range tt.headers {
req.Header.Set(key, value)
}
router.ServeHTTP(w, req)
if capturedIP != tt.expected {
t.Errorf("getClientIP() = %q, want %q", capturedIP, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,528 @@
package api
import (
"strings"
"testing"
"time"
)
func TestParsePullCount(t *testing.T) {
tests := []struct {
name string
input string
expected int64
}{
{"plain number", "1000", 1000},
{"thousands K", "1.5K", 1500},
{"millions M", "2.3M", 2300000},
{"billions B", "1B", 1000000000},
{"whole K", "500K", 500000},
{"decimal M", "60.3M", 60300000},
{"with whitespace", " 100K ", 100000},
{"empty string", "", 0},
{"invalid", "abc", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parsePullCount(tt.input)
if result != tt.expected {
t.Errorf("parsePullCount(%q) = %d, want %d", tt.input, result, tt.expected)
}
})
}
}
func TestDecodeHTMLEntities(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"apostrophe numeric", "It&#39;s", "It's"},
{"quote numeric", "&#34;Hello&#34;", "\"Hello\""},
{"quote named", "&quot;World&quot;", "\"World\""},
{"ampersand", "A &amp; B", "A & B"},
{"less than", "1 &lt; 2", "1 < 2"},
{"greater than", "2 &gt; 1", "2 > 1"},
{"nbsp", "Hello&nbsp;World", "Hello World"},
{"multiple entities", "&lt;div&gt;&amp;&lt;/div&gt;", "<div>&</div>"},
{"no entities", "Plain text", "Plain text"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := decodeHTMLEntities(tt.input)
if result != tt.expected {
t.Errorf("decodeHTMLEntities(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestParseRelativeTime(t *testing.T) {
now := time.Now()
tests := []struct {
name string
input string
wantEmpty bool
checkDelta time.Duration
}{
{"2 weeks ago", "2 weeks ago", false, 14 * 24 * time.Hour},
{"1 month ago", "1 month ago", false, 30 * 24 * time.Hour},
{"3 days ago", "3 days ago", false, 3 * 24 * time.Hour},
{"5 hours ago", "5 hours ago", false, 5 * time.Hour},
{"30 minutes ago", "30 minutes ago", false, 30 * time.Minute},
{"1 year ago", "1 year ago", false, 365 * 24 * time.Hour},
{"empty string", "", true, 0},
{"invalid format", "recently", true, 0},
{"uppercase", "2 WEEKS AGO", false, 14 * 24 * time.Hour},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseRelativeTime(tt.input)
if tt.wantEmpty {
if result != "" {
t.Errorf("parseRelativeTime(%q) = %q, want empty string", tt.input, result)
}
return
}
// Parse the result as RFC3339
parsed, err := time.Parse(time.RFC3339, result)
if err != nil {
t.Fatalf("failed to parse result %q: %v", result, err)
}
// Check that the delta is approximately correct (within 1 minute tolerance)
expectedTime := now.Add(-tt.checkDelta)
diff := parsed.Sub(expectedTime)
if diff < -time.Minute || diff > time.Minute {
t.Errorf("parseRelativeTime(%q) = %v, expected around %v", tt.input, parsed, expectedTime)
}
})
}
}
func TestParseSizeToBytes(t *testing.T) {
tests := []struct {
name string
input string
expected int64
}{
{"gigabytes", "2.0GB", 2 * 1024 * 1024 * 1024},
{"megabytes", "500MB", 500 * 1024 * 1024},
{"kilobytes", "100KB", 100 * 1024},
{"decimal GB", "1.5GB", int64(1.5 * 1024 * 1024 * 1024)},
{"plain number", "1024", 1024},
{"with whitespace", " 1GB ", 1 * 1024 * 1024 * 1024},
{"empty", "", 0},
{"invalid", "abc", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseSizeToBytes(tt.input)
if result != tt.expected {
t.Errorf("parseSizeToBytes(%q) = %d, want %d", tt.input, result, tt.expected)
}
})
}
}
func TestFormatParamCount(t *testing.T) {
tests := []struct {
name string
input int64
expected string
}{
{"billions", 13900000000, "13.9B"},
{"single billion", 1000000000, "1.0B"},
{"millions", 500000000, "500.0M"},
{"single million", 1000000, "1.0M"},
{"thousands", 500000, "500.0K"},
{"single thousand", 1000, "1.0K"},
{"small number", 500, "500"},
{"zero", 0, "0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatParamCount(tt.input)
if result != tt.expected {
t.Errorf("formatParamCount(%d) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestParseParamSizeToFloat(t *testing.T) {
tests := []struct {
name string
input string
expected float64
}{
{"8b", "8b", 8.0},
{"70b", "70b", 70.0},
{"1.5b", "1.5b", 1.5},
{"500m to billions", "500m", 0.5},
{"uppercase B", "8B", 8.0},
{"uppercase M", "500M", 0.5},
{"with whitespace", " 8b ", 8.0},
{"empty", "", 0},
{"invalid", "abc", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseParamSizeToFloat(tt.input)
if result != tt.expected {
t.Errorf("parseParamSizeToFloat(%q) = %f, want %f", tt.input, result, tt.expected)
}
})
}
}
func TestGetSizeRange(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"small 1b", "1b", "small"},
{"small 3b", "3b", "small"},
{"medium 4b", "4b", "medium"},
{"medium 8b", "8b", "medium"},
{"medium 13b", "13b", "medium"},
{"large 14b", "14b", "large"},
{"large 70b", "70b", "large"},
{"xlarge 405b", "405b", "xlarge"},
{"empty", "", ""},
{"invalid", "abc", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getSizeRange(tt.input)
if result != tt.expected {
t.Errorf("getSizeRange(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestGetContextRange(t *testing.T) {
tests := []struct {
name string
input int64
expected string
}{
{"standard 4K", 4096, "standard"},
{"standard 8K", 8192, "standard"},
{"extended 16K", 16384, "extended"},
{"extended 32K", 32768, "extended"},
{"large 64K", 65536, "large"},
{"large 128K", 131072, "large"},
{"unlimited 256K", 262144, "unlimited"},
{"unlimited 1M", 1048576, "unlimited"},
{"zero", 0, ""},
{"negative", -1, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getContextRange(tt.input)
if result != tt.expected {
t.Errorf("getContextRange(%d) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestExtractFamily(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"llama3.2", "llama3.2", "llama"},
{"qwen2.5", "qwen2.5", "qwen"},
{"mistral", "mistral", "mistral"},
{"deepseek-r1", "deepseek-r1", "deepseek"},
{"phi_3", "phi_3", "phi"},
{"community model", "username/custom-llama", "custom"},
{"with version", "llama3.2:8b", "llama"},
{"numbers only", "123model", ""},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractFamily(tt.input)
if result != tt.expected {
t.Errorf("extractFamily(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestInferModelType(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"official llama", "llama3.2", "official"},
{"official mistral", "mistral", "official"},
{"community model", "username/model", "community"},
{"nested community", "org/subdir/model", "community"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := inferModelType(tt.input)
if result != tt.expected {
t.Errorf("inferModelType(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestModelMatchesSizeRanges(t *testing.T) {
tests := []struct {
name string
tags []string
sizeRanges []string
expected bool
}{
{
name: "matches small",
tags: []string{"1b", "3b"},
sizeRanges: []string{"small"},
expected: true,
},
{
name: "matches medium",
tags: []string{"8b", "14b"},
sizeRanges: []string{"medium"},
expected: true,
},
{
name: "matches large",
tags: []string{"70b"},
sizeRanges: []string{"large"},
expected: true,
},
{
name: "matches multiple ranges",
tags: []string{"8b", "70b"},
sizeRanges: []string{"medium", "large"},
expected: true,
},
{
name: "no match",
tags: []string{"8b"},
sizeRanges: []string{"large", "xlarge"},
expected: false,
},
{
name: "empty tags",
tags: []string{},
sizeRanges: []string{"medium"},
expected: false,
},
{
name: "empty ranges",
tags: []string{"8b"},
sizeRanges: []string{},
expected: false,
},
{
name: "non-size tags",
tags: []string{"latest", "fp16"},
sizeRanges: []string{"medium"},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := modelMatchesSizeRanges(tt.tags, tt.sizeRanges)
if result != tt.expected {
t.Errorf("modelMatchesSizeRanges(%v, %v) = %v, want %v", tt.tags, tt.sizeRanges, result, tt.expected)
}
})
}
}
func TestParseOllamaParams(t *testing.T) {
tests := []struct {
name string
input string
expected map[string]any
}{
{
name: "temperature",
input: "temperature 0.8",
expected: map[string]any{
"temperature": 0.8,
},
},
{
name: "multiple params",
input: "temperature 0.8\nnum_ctx 4096\nstop <|im_end|>",
expected: map[string]any{
"temperature": 0.8,
"num_ctx": float64(4096),
"stop": "<|im_end|>",
},
},
{
name: "empty input",
input: "",
expected: map[string]any{},
},
{
name: "whitespace only",
input: " \n \n ",
expected: map[string]any{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseOllamaParams(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("parseOllamaParams result length = %d, want %d", len(result), len(tt.expected))
return
}
for k, v := range tt.expected {
if result[k] != v {
t.Errorf("parseOllamaParams[%q] = %v, want %v", k, result[k], v)
}
}
})
}
}
func TestParseLibraryHTML(t *testing.T) {
// Test with minimal valid HTML structure
html := `
<a href="/library/llama3.2" class="group flex">
<p class="text-neutral-800">A foundation model</p>
<span x-test-pull-count>1.5M</span>
<span x-test-size>8b</span>
<span x-test-size>70b</span>
<span x-test-capability>vision</span>
<span x-test-updated>2 weeks ago</span>
</a>
<a href="/library/mistral" class="group flex">
<p class="text-neutral-800">Fast model</p>
<span x-test-pull-count>500K</span>
<span x-test-size>7b</span>
</a>
`
models, err := parseLibraryHTML(html)
if err != nil {
t.Fatalf("parseLibraryHTML failed: %v", err)
}
if len(models) != 2 {
t.Fatalf("expected 2 models, got %d", len(models))
}
// Find llama3.2 model
var llama *ScrapedModel
for i := range models {
if models[i].Slug == "llama3.2" {
llama = &models[i]
break
}
}
if llama == nil {
t.Fatal("llama3.2 model not found")
}
if llama.Description != "A foundation model" {
t.Errorf("description = %q, want %q", llama.Description, "A foundation model")
}
if llama.PullCount != 1500000 {
t.Errorf("pull count = %d, want 1500000", llama.PullCount)
}
if len(llama.Tags) != 2 || llama.Tags[0] != "8b" || llama.Tags[1] != "70b" {
t.Errorf("tags = %v, want [8b, 70b]", llama.Tags)
}
if len(llama.Capabilities) != 1 || llama.Capabilities[0] != "vision" {
t.Errorf("capabilities = %v, want [vision]", llama.Capabilities)
}
if !strings.HasPrefix(llama.URL, "https://ollama.com/library/") {
t.Errorf("URL = %q, want prefix https://ollama.com/library/", llama.URL)
}
}
func TestParseModelPageForSizes(t *testing.T) {
html := `
<a href="/library/llama3.2:8b">
<span>8b</span>
<span>2.0GB</span>
</a>
<a href="/library/llama3.2:70b">
<span>70b</span>
<span>40.5GB</span>
</a>
<a href="/library/llama3.2:1b">
<span>1b</span>
<span>500MB</span>
</a>
`
sizes, err := parseModelPageForSizes(html)
if err != nil {
t.Fatalf("parseModelPageForSizes failed: %v", err)
}
expected := map[string]int64{
"8b": int64(2.0 * 1024 * 1024 * 1024),
"70b": int64(40.5 * 1024 * 1024 * 1024),
"1b": int64(500 * 1024 * 1024),
}
for tag, expectedSize := range expected {
if sizes[tag] != expectedSize {
t.Errorf("sizes[%q] = %d, want %d", tag, sizes[tag], expectedSize)
}
}
}
func TestStripHTML(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"simple tags", "<p>Hello</p>", " Hello "},
{"nested tags", "<div><span>Text</span></div>", " Text "},
{"self-closing", "<br/>Line<br/>", " Line "},
{"no tags", "Plain text", "Plain text"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := stripHTML(tt.input)
if result != tt.expected {
t.Errorf("stripHTML(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -5,10 +5,12 @@ import (
"log"
"github.com/gin-gonic/gin"
"vessel-backend/internal/backends"
)
// SetupRoutes configures all API routes
func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string) {
func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string, registry *backends.Registry) {
// Initialize Ollama service with official client
ollamaService, err := NewOllamaService(ollamaURL)
if err != nil {
@@ -97,6 +99,24 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string)
models.GET("/remote/status", modelRegistry.SyncStatusHandler())
}
// Unified AI routes (multi-backend support)
if registry != nil {
aiHandlers := NewAIHandlers(registry)
ai := v1.Group("/ai")
{
// Backend management
ai.GET("/backends", aiHandlers.ListBackendsHandler())
ai.POST("/backends/discover", aiHandlers.DiscoverBackendsHandler())
ai.POST("/backends/active", aiHandlers.SetActiveHandler())
ai.GET("/backends/:type/health", aiHandlers.HealthCheckHandler())
ai.POST("/backends/register", aiHandlers.RegisterBackendHandler())
// Unified model and chat endpoints (route to active backend)
ai.GET("/models", aiHandlers.ListModelsHandler())
ai.POST("/chat", aiHandlers.ChatHandler())
}
}
// Ollama API routes (using official client)
if ollamaService != nil {
ollama := v1.Group("/ollama")

View File

@@ -0,0 +1,186 @@
package api
import (
"testing"
)
func TestCleanHTML(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "removes simple tags",
input: "<b>bold</b> text",
expected: "bold text",
},
{
name: "removes nested tags",
input: "<div><span>nested</span></div>",
expected: "nested",
},
{
name: "decodes html entities",
input: "&amp; &lt; &gt; &quot;",
expected: "& < > \"",
},
{
name: "decodes apostrophe",
input: "it&#39;s working",
expected: "it's working",
},
{
name: "replaces nbsp with space",
input: "word&nbsp;word",
expected: "word word",
},
{
name: "normalizes whitespace",
input: " multiple spaces ",
expected: "multiple spaces",
},
{
name: "handles empty string",
input: "",
expected: "",
},
{
name: "handles plain text",
input: "no html here",
expected: "no html here",
},
{
name: "handles complex html",
input: "<a href=\"https://example.com\">Link &amp; Text</a>",
expected: "Link & Text",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleanHTML(tt.input)
if result != tt.expected {
t.Errorf("cleanHTML(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestDecodeURL(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "extracts url from uddg parameter",
input: "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpath&rut=abc",
expected: "https://example.com/path",
},
{
name: "adds https to protocol-relative urls",
input: "//example.com/path",
expected: "https://example.com/path",
},
{
name: "returns normal urls unchanged",
input: "https://example.com/page",
expected: "https://example.com/page",
},
{
name: "handles http urls",
input: "http://example.com",
expected: "http://example.com",
},
{
name: "handles empty string",
input: "",
expected: "",
},
{
name: "handles uddg with special chars",
input: "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dtest",
expected: "https://example.com/search?q=test",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := decodeURL(tt.input)
if result != tt.expected {
t.Errorf("decodeURL(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestParseDuckDuckGoResults(t *testing.T) {
// Test with realistic DuckDuckGo HTML structure
html := `
<div class="result results_links results_links_deep web-result">
<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage1">Example Page 1</a>
<a class="result__snippet">This is the first result snippet.</a>
</div>
</div>
<div class="result results_links results_links_deep web-result">
<a class="result__a" href="https://example.org/page2">Example Page 2</a>
<a class="result__snippet">Second result snippet here.</a>
</div>
</div>
`
results := parseDuckDuckGoResults(html, 10)
if len(results) < 1 {
t.Fatalf("expected at least 1 result, got %d", len(results))
}
// Check first result
if results[0].Title != "Example Page 1" {
t.Errorf("first result title = %q, want %q", results[0].Title, "Example Page 1")
}
if results[0].URL != "https://example.com/page1" {
t.Errorf("first result URL = %q, want %q", results[0].URL, "https://example.com/page1")
}
}
func TestParseDuckDuckGoResultsMaxResults(t *testing.T) {
// Create HTML with many results
html := ""
for i := 0; i < 20; i++ {
html += `<div class="result results_links results_links_deep web-result">
<a class="result__a" href="https://example.com/page">Title</a>
<a class="result__snippet">Snippet</a>
</div></div>`
}
results := parseDuckDuckGoResults(html, 5)
if len(results) > 5 {
t.Errorf("expected max 5 results, got %d", len(results))
}
}
func TestParseDuckDuckGoResultsSkipsDuckDuckGoLinks(t *testing.T) {
html := `
<div class="result results_links results_links_deep web-result">
<a class="result__a" href="https://duckduckgo.com/something">DDG Internal</a>
<a class="result__snippet">Internal link</a>
</div>
</div>
<div class="result results_links results_links_deep web-result">
<a class="result__a" href="https://example.com/page">External Page</a>
<a class="result__snippet">External snippet</a>
</div>
</div>
`
results := parseDuckDuckGoResults(html, 10)
for _, r := range results {
if r.URL == "https://duckduckgo.com/something" {
t.Error("should have filtered out duckduckgo.com link")
}
}
}

View File

@@ -0,0 +1,210 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestTruncateOutput(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "short string unchanged",
input: "hello world",
expected: "hello world",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "exactly at limit",
input: strings.Repeat("a", MaxOutputSize),
expected: strings.Repeat("a", MaxOutputSize),
},
{
name: "over limit truncated",
input: strings.Repeat("a", MaxOutputSize+100),
expected: strings.Repeat("a", MaxOutputSize) + "\n... (output truncated)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncateOutput(tt.input)
if result != tt.expected {
// For long strings, just check length and suffix
if len(tt.input) > MaxOutputSize {
if !strings.HasSuffix(result, "(output truncated)") {
t.Error("truncated output should have truncation message")
}
if len(result) > MaxOutputSize+50 {
t.Errorf("truncated output too long: %d", len(result))
}
} else {
t.Errorf("truncateOutput() = %q, want %q", result, tt.expected)
}
}
})
}
}
func TestExecuteToolHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("rejects invalid request", func(t *testing.T) {
router := gin.New()
router.POST("/tools/execute", ExecuteToolHandler())
body := `{"language": "invalid", "code": "print(1)"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/tools/execute", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
t.Run("rejects javascript on backend", func(t *testing.T) {
router := gin.New()
router.POST("/tools/execute", ExecuteToolHandler())
reqBody := ExecuteToolRequest{
Language: "javascript",
Code: "return 1 + 1",
}
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/tools/execute", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
var resp ExecuteToolResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Success {
t.Error("javascript should not be supported on backend")
}
if !strings.Contains(resp.Error, "browser") {
t.Errorf("error should mention browser, got: %s", resp.Error)
}
})
t.Run("executes simple python", func(t *testing.T) {
router := gin.New()
router.POST("/tools/execute", ExecuteToolHandler())
reqBody := ExecuteToolRequest{
Language: "python",
Code: "print('{\"result\": 42}')",
Args: map[string]interface{}{},
Timeout: 5,
}
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/tools/execute", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
var resp ExecuteToolResponse
json.Unmarshal(w.Body.Bytes(), &resp)
// This test depends on python3 being available
// If python isn't available, the test should still pass (checking error handling)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
})
t.Run("passes args to python", func(t *testing.T) {
router := gin.New()
router.POST("/tools/execute", ExecuteToolHandler())
reqBody := ExecuteToolRequest{
Language: "python",
Code: "import json; print(json.dumps({'doubled': args['value'] * 2}))",
Args: map[string]interface{}{"value": 21},
Timeout: 5,
}
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/tools/execute", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
var resp ExecuteToolResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Success {
// Check result contains the doubled value
if result, ok := resp.Result.(map[string]interface{}); ok {
if doubled, ok := result["doubled"].(float64); ok {
if doubled != 42 {
t.Errorf("expected doubled=42, got %v", doubled)
}
}
}
}
// If python isn't available, test passes anyway
})
t.Run("uses default timeout", func(t *testing.T) {
router := gin.New()
router.POST("/tools/execute", ExecuteToolHandler())
// Request without timeout should use default (30s)
reqBody := ExecuteToolRequest{
Language: "python",
Code: "print('ok')",
}
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/tools/execute", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
// Should complete successfully (not timeout)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
})
t.Run("caps timeout at 60s", func(t *testing.T) {
router := gin.New()
router.POST("/tools/execute", ExecuteToolHandler())
// Request with excessive timeout
reqBody := ExecuteToolRequest{
Language: "python",
Code: "print('ok')",
Timeout: 999, // Should be capped to 30 (default)
}
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/tools/execute", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
// Should complete (timeout was capped, not honored)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
})
}

View File

@@ -0,0 +1,85 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestCompareVersions(t *testing.T) {
tests := []struct {
name string
current string
latest string
expected bool
}{
// Basic comparisons
{"newer major version", "1.0.0", "2.0.0", true},
{"newer minor version", "1.0.0", "1.1.0", true},
{"newer patch version", "1.0.0", "1.0.1", true},
{"same version", "1.0.0", "1.0.0", false},
{"older version", "2.0.0", "1.0.0", false},
// With v prefix
{"v prefix on both", "v1.0.0", "v1.1.0", true},
{"v prefix on current only", "v1.0.0", "1.1.0", true},
{"v prefix on latest only", "1.0.0", "v1.1.0", true},
// Different segment counts
{"more segments in latest", "1.0", "1.0.1", true},
{"more segments in current", "1.0.1", "1.1", true},
{"single segment", "1", "2", true},
// Pre-release versions (strips suffix after -)
{"pre-release current", "1.0.0-beta", "1.0.0", false},
{"pre-release latest", "1.0.0", "1.0.1-beta", true},
// Edge cases
{"empty latest", "1.0.0", "", false},
{"empty current", "", "1.0.0", false},
{"both empty", "", "", false},
// Real-world scenarios
{"typical update", "0.5.1", "0.5.2", true},
{"major bump", "0.9.9", "1.0.0", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := compareVersions(tt.current, tt.latest)
if result != tt.expected {
t.Errorf("compareVersions(%q, %q) = %v, want %v",
tt.current, tt.latest, result, tt.expected)
}
})
}
}
func TestVersionHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("returns current version", func(t *testing.T) {
router := gin.New()
router.GET("/version", VersionHandler("1.2.3"))
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/version", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var info VersionInfo
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if info.Current != "1.2.3" {
t.Errorf("expected current version '1.2.3', got '%s'", info.Current)
}
})
}

View File

@@ -0,0 +1,98 @@
package backends
import (
"context"
)
// LLMBackend defines the interface for LLM backend implementations.
// All backends (Ollama, llama.cpp, LM Studio) must implement this interface.
type LLMBackend interface {
// Type returns the backend type identifier
Type() BackendType
// Config returns the backend configuration
Config() BackendConfig
// HealthCheck verifies the backend is reachable and operational
HealthCheck(ctx context.Context) error
// ListModels returns all models available from this backend
ListModels(ctx context.Context) ([]Model, error)
// StreamChat sends a chat request and returns a channel for streaming responses.
// The channel is closed when the stream completes or an error occurs.
// Callers should check ChatChunk.Error for stream errors.
StreamChat(ctx context.Context, req *ChatRequest) (<-chan ChatChunk, error)
// Chat sends a non-streaming chat request and returns the final response
Chat(ctx context.Context, req *ChatRequest) (*ChatChunk, error)
// Capabilities returns what features this backend supports
Capabilities() BackendCapabilities
// Info returns detailed information about the backend including status
Info(ctx context.Context) BackendInfo
}
// ModelManager extends LLMBackend with model management capabilities.
// Only Ollama implements this interface.
type ModelManager interface {
LLMBackend
// PullModel downloads a model from the registry.
// Returns a channel for progress updates.
PullModel(ctx context.Context, name string) (<-chan PullProgress, error)
// DeleteModel removes a model from local storage
DeleteModel(ctx context.Context, name string) error
// CreateModel creates a custom model with the given Modelfile content
CreateModel(ctx context.Context, name string, modelfile string) (<-chan CreateProgress, error)
// CopyModel creates a copy of an existing model
CopyModel(ctx context.Context, source, destination string) error
// ShowModel returns detailed information about a specific model
ShowModel(ctx context.Context, name string) (*ModelDetails, error)
}
// EmbeddingProvider extends LLMBackend with embedding capabilities.
type EmbeddingProvider interface {
LLMBackend
// Embed generates embeddings for the given input
Embed(ctx context.Context, model string, input []string) ([][]float64, error)
}
// PullProgress represents progress during model download
type PullProgress struct {
Status string `json:"status"`
Digest string `json:"digest,omitempty"`
Total int64 `json:"total,omitempty"`
Completed int64 `json:"completed,omitempty"`
Error string `json:"error,omitempty"`
}
// CreateProgress represents progress during model creation
type CreateProgress struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// ModelDetails contains detailed information about a model
type ModelDetails struct {
Name string `json:"name"`
ModifiedAt string `json:"modified_at"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Format string `json:"format"`
Family string `json:"family"`
Families []string `json:"families"`
ParamSize string `json:"parameter_size"`
QuantLevel string `json:"quantization_level"`
Template string `json:"template"`
System string `json:"system"`
License string `json:"license"`
Modelfile string `json:"modelfile"`
Parameters map[string]string `json:"parameters"`
}

View File

@@ -0,0 +1,624 @@
package ollama
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"vessel-backend/internal/backends"
)
// Adapter implements the LLMBackend interface for Ollama.
// It also implements ModelManager and EmbeddingProvider.
type Adapter struct {
config backends.BackendConfig
httpClient *http.Client
baseURL *url.URL
}
// Ensure Adapter implements all required interfaces
var (
_ backends.LLMBackend = (*Adapter)(nil)
_ backends.ModelManager = (*Adapter)(nil)
_ backends.EmbeddingProvider = (*Adapter)(nil)
)
// NewAdapter creates a new Ollama backend adapter
func NewAdapter(config backends.BackendConfig) (*Adapter, error) {
if config.Type != backends.BackendTypeOllama {
return nil, fmt.Errorf("invalid backend type: expected %s, got %s", backends.BackendTypeOllama, config.Type)
}
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
baseURL, err := url.Parse(config.BaseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
return &Adapter{
config: config,
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
}
// Type returns the backend type
func (a *Adapter) Type() backends.BackendType {
return backends.BackendTypeOllama
}
// Config returns the backend configuration
func (a *Adapter) Config() backends.BackendConfig {
return a.config
}
// Capabilities returns what features this backend supports
func (a *Adapter) Capabilities() backends.BackendCapabilities {
return backends.OllamaCapabilities()
}
// HealthCheck verifies the backend is reachable
func (a *Adapter) HealthCheck(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/api/version", nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to reach Ollama: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Ollama returned status %d", resp.StatusCode)
}
return nil
}
// ollamaListResponse represents the response from /api/tags
type ollamaListResponse struct {
Models []ollamaModel `json:"models"`
}
type ollamaModel struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModifiedAt string `json:"modified_at"`
Details ollamaModelDetails `json:"details"`
}
type ollamaModelDetails struct {
Family string `json:"family"`
QuantLevel string `json:"quantization_level"`
ParamSize string `json:"parameter_size"`
}
// ListModels returns all models available from Ollama
func (a *Adapter) ListModels(ctx context.Context) ([]backends.Model, error) {
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/api/tags", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to list models: %w", err)
}
defer resp.Body.Close()
var listResp ollamaListResponse
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
models := make([]backends.Model, len(listResp.Models))
for i, m := range listResp.Models {
models[i] = backends.Model{
ID: m.Name,
Name: m.Name,
Size: m.Size,
ModifiedAt: m.ModifiedAt,
Family: m.Details.Family,
QuantLevel: m.Details.QuantLevel,
}
}
return models, nil
}
// Chat sends a non-streaming chat request
func (a *Adapter) Chat(ctx context.Context, req *backends.ChatRequest) (*backends.ChatChunk, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
// Convert to Ollama format
ollamaReq := a.convertChatRequest(req)
ollamaReq["stream"] = false
body, err := json.Marshal(ollamaReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/chat", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("chat request failed: %w", err)
}
defer resp.Body.Close()
var ollamaResp ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return a.convertChatResponse(&ollamaResp), nil
}
// StreamChat sends a streaming chat request
func (a *Adapter) StreamChat(ctx context.Context, req *backends.ChatRequest) (<-chan backends.ChatChunk, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
// Convert to Ollama format
ollamaReq := a.convertChatRequest(req)
ollamaReq["stream"] = true
body, err := json.Marshal(ollamaReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create HTTP request without timeout for streaming
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/chat", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// Use a client without timeout for streaming
client := &http.Client{}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("chat request failed: %w", err)
}
chunkCh := make(chan backends.ChatChunk)
go func() {
defer close(chunkCh)
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
}
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var ollamaResp ollamaChatResponse
if err := json.Unmarshal(line, &ollamaResp); err != nil {
chunkCh <- backends.ChatChunk{Error: fmt.Sprintf("failed to parse response: %v", err)}
return
}
chunkCh <- *a.convertChatResponse(&ollamaResp)
if ollamaResp.Done {
return
}
}
if err := scanner.Err(); err != nil && ctx.Err() == nil {
chunkCh <- backends.ChatChunk{Error: fmt.Sprintf("stream error: %v", err)}
}
}()
return chunkCh, nil
}
// Info returns detailed information about the backend
func (a *Adapter) Info(ctx context.Context) backends.BackendInfo {
info := backends.BackendInfo{
Type: backends.BackendTypeOllama,
BaseURL: a.config.BaseURL,
Capabilities: a.Capabilities(),
}
// Try to get version
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/api/version", nil)
if err != nil {
info.Status = backends.BackendStatusDisconnected
info.Error = err.Error()
return info
}
resp, err := a.httpClient.Do(req)
if err != nil {
info.Status = backends.BackendStatusDisconnected
info.Error = err.Error()
return info
}
defer resp.Body.Close()
var versionResp struct {
Version string `json:"version"`
}
if err := json.NewDecoder(resp.Body).Decode(&versionResp); err != nil {
info.Status = backends.BackendStatusDisconnected
info.Error = err.Error()
return info
}
info.Status = backends.BackendStatusConnected
info.Version = versionResp.Version
return info
}
// ShowModel returns detailed information about a specific model
func (a *Adapter) ShowModel(ctx context.Context, name string) (*backends.ModelDetails, error) {
body, err := json.Marshal(map[string]string{"name": name})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/show", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to show model: %w", err)
}
defer resp.Body.Close()
var showResp struct {
Modelfile string `json:"modelfile"`
Template string `json:"template"`
System string `json:"system"`
Details struct {
Family string `json:"family"`
ParamSize string `json:"parameter_size"`
QuantLevel string `json:"quantization_level"`
} `json:"details"`
}
if err := json.NewDecoder(resp.Body).Decode(&showResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &backends.ModelDetails{
Name: name,
Family: showResp.Details.Family,
ParamSize: showResp.Details.ParamSize,
QuantLevel: showResp.Details.QuantLevel,
Template: showResp.Template,
System: showResp.System,
Modelfile: showResp.Modelfile,
}, nil
}
// PullModel downloads a model from the registry
func (a *Adapter) PullModel(ctx context.Context, name string) (<-chan backends.PullProgress, error) {
body, err := json.Marshal(map[string]interface{}{"name": name, "stream": true})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/pull", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to pull model: %w", err)
}
progressCh := make(chan backends.PullProgress)
go func() {
defer close(progressCh)
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
}
var progress struct {
Status string `json:"status"`
Digest string `json:"digest"`
Total int64 `json:"total"`
Completed int64 `json:"completed"`
}
if err := json.Unmarshal(scanner.Bytes(), &progress); err != nil {
progressCh <- backends.PullProgress{Error: err.Error()}
return
}
progressCh <- backends.PullProgress{
Status: progress.Status,
Digest: progress.Digest,
Total: progress.Total,
Completed: progress.Completed,
}
}
if err := scanner.Err(); err != nil && ctx.Err() == nil {
progressCh <- backends.PullProgress{Error: err.Error()}
}
}()
return progressCh, nil
}
// DeleteModel removes a model from local storage
func (a *Adapter) DeleteModel(ctx context.Context, name string) error {
body, err := json.Marshal(map[string]string{"name": name})
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "DELETE", a.baseURL.String()+"/api/delete", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to delete model: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete failed: %s", string(bodyBytes))
}
return nil
}
// CreateModel creates a custom model with the given Modelfile content
func (a *Adapter) CreateModel(ctx context.Context, name string, modelfile string) (<-chan backends.CreateProgress, error) {
body, err := json.Marshal(map[string]interface{}{
"name": name,
"modelfile": modelfile,
"stream": true,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/create", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to create model: %w", err)
}
progressCh := make(chan backends.CreateProgress)
go func() {
defer close(progressCh)
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
}
var progress struct {
Status string `json:"status"`
}
if err := json.Unmarshal(scanner.Bytes(), &progress); err != nil {
progressCh <- backends.CreateProgress{Error: err.Error()}
return
}
progressCh <- backends.CreateProgress{Status: progress.Status}
}
if err := scanner.Err(); err != nil && ctx.Err() == nil {
progressCh <- backends.CreateProgress{Error: err.Error()}
}
}()
return progressCh, nil
}
// CopyModel creates a copy of an existing model
func (a *Adapter) CopyModel(ctx context.Context, source, destination string) error {
body, err := json.Marshal(map[string]string{
"source": source,
"destination": destination,
})
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/copy", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to copy model: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("copy failed: %s", string(bodyBytes))
}
return nil
}
// Embed generates embeddings for the given input
func (a *Adapter) Embed(ctx context.Context, model string, input []string) ([][]float64, error) {
body, err := json.Marshal(map[string]interface{}{
"model": model,
"input": input,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/embed", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("embed request failed: %w", err)
}
defer resp.Body.Close()
var embedResp struct {
Embeddings [][]float64 `json:"embeddings"`
}
if err := json.NewDecoder(resp.Body).Decode(&embedResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return embedResp.Embeddings, nil
}
// ollamaChatResponse represents the response from /api/chat
type ollamaChatResponse struct {
Model string `json:"model"`
CreatedAt string `json:"created_at"`
Message ollamaChatMessage `json:"message"`
Done bool `json:"done"`
DoneReason string `json:"done_reason,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
}
type ollamaChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Images []string `json:"images,omitempty"`
ToolCalls []ollamaToolCall `json:"tool_calls,omitempty"`
}
type ollamaToolCall struct {
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
// convertChatRequest converts a backends.ChatRequest to Ollama format
func (a *Adapter) convertChatRequest(req *backends.ChatRequest) map[string]interface{} {
messages := make([]map[string]interface{}, len(req.Messages))
for i, msg := range req.Messages {
m := map[string]interface{}{
"role": msg.Role,
"content": msg.Content,
}
if len(msg.Images) > 0 {
m["images"] = msg.Images
}
messages[i] = m
}
ollamaReq := map[string]interface{}{
"model": req.Model,
"messages": messages,
}
// Add optional parameters
if req.Options != nil {
ollamaReq["options"] = req.Options
}
if len(req.Tools) > 0 {
ollamaReq["tools"] = req.Tools
}
return ollamaReq
}
// convertChatResponse converts an Ollama response to backends.ChatChunk
func (a *Adapter) convertChatResponse(resp *ollamaChatResponse) *backends.ChatChunk {
chunk := &backends.ChatChunk{
Model: resp.Model,
CreatedAt: resp.CreatedAt,
Done: resp.Done,
DoneReason: resp.DoneReason,
PromptEvalCount: resp.PromptEvalCount,
EvalCount: resp.EvalCount,
}
if resp.Message.Role != "" || resp.Message.Content != "" {
msg := &backends.ChatMessage{
Role: resp.Message.Role,
Content: resp.Message.Content,
Images: resp.Message.Images,
}
// Convert tool calls
for _, tc := range resp.Message.ToolCalls {
msg.ToolCalls = append(msg.ToolCalls, backends.ToolCall{
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: tc.Function.Name,
Arguments: string(tc.Function.Arguments),
},
})
}
chunk.Message = msg
}
return chunk
}

View File

@@ -0,0 +1,574 @@
package ollama
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"vessel-backend/internal/backends"
)
func TestAdapter_Type(t *testing.T) {
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
})
if adapter.Type() != backends.BackendTypeOllama {
t.Errorf("Type() = %v, want %v", adapter.Type(), backends.BackendTypeOllama)
}
}
func TestAdapter_Config(t *testing.T) {
cfg := backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
Enabled: true,
}
adapter, _ := NewAdapter(cfg)
got := adapter.Config()
if got.Type != cfg.Type {
t.Errorf("Config().Type = %v, want %v", got.Type, cfg.Type)
}
if got.BaseURL != cfg.BaseURL {
t.Errorf("Config().BaseURL = %v, want %v", got.BaseURL, cfg.BaseURL)
}
}
func TestAdapter_Capabilities(t *testing.T) {
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
})
caps := adapter.Capabilities()
if !caps.CanListModels {
t.Error("Ollama adapter should support listing models")
}
if !caps.CanPullModels {
t.Error("Ollama adapter should support pulling models")
}
if !caps.CanDeleteModels {
t.Error("Ollama adapter should support deleting models")
}
if !caps.CanCreateModels {
t.Error("Ollama adapter should support creating models")
}
if !caps.CanStreamChat {
t.Error("Ollama adapter should support streaming chat")
}
if !caps.CanEmbed {
t.Error("Ollama adapter should support embeddings")
}
}
func TestAdapter_HealthCheck(t *testing.T) {
t.Run("healthy server", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/api/version" {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"version": "0.1.0"})
}
}))
defer server.Close()
adapter, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
if err != nil {
t.Fatalf("Failed to create adapter: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := adapter.HealthCheck(ctx); err != nil {
t.Errorf("HealthCheck() error = %v, want nil", err)
}
})
t.Run("unreachable server", func(t *testing.T) {
adapter, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:19999", // unlikely to be running
})
if err != nil {
t.Fatalf("Failed to create adapter: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if err := adapter.HealthCheck(ctx); err == nil {
t.Error("HealthCheck() expected error for unreachable server")
}
})
}
func TestAdapter_ListModels(t *testing.T) {
t.Run("returns model list", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
resp := map[string]interface{}{
"models": []map[string]interface{}{
{
"name": "llama3.2:8b",
"size": int64(4700000000),
"modified_at": "2024-01-15T10:30:00Z",
"details": map[string]interface{}{
"family": "llama",
"quantization_level": "Q4_K_M",
},
},
{
"name": "mistral:7b",
"size": int64(4100000000),
"modified_at": "2024-01-14T08:00:00Z",
"details": map[string]interface{}{
"family": "mistral",
"quantization_level": "Q4_0",
},
},
},
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
ctx := context.Background()
models, err := adapter.ListModels(ctx)
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
if len(models) != 2 {
t.Errorf("ListModels() returned %d models, want 2", len(models))
}
if models[0].Name != "llama3.2:8b" {
t.Errorf("First model name = %q, want %q", models[0].Name, "llama3.2:8b")
}
if models[0].Family != "llama" {
t.Errorf("First model family = %q, want %q", models[0].Family, "llama")
}
})
t.Run("handles empty model list", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
resp := map[string]interface{}{
"models": []map[string]interface{}{},
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
models, err := adapter.ListModels(context.Background())
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
if len(models) != 0 {
t.Errorf("ListModels() returned %d models, want 0", len(models))
}
})
}
func TestAdapter_Chat(t *testing.T) {
t.Run("non-streaming chat", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/chat" && r.Method == "POST" {
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
// Check stream is false
if stream, ok := req["stream"].(bool); !ok || stream {
t.Error("Expected stream=false for non-streaming chat")
}
resp := map[string]interface{}{
"model": "llama3.2:8b",
"message": map[string]interface{}{"role": "assistant", "content": "Hello! How can I help you?"},
"done": true,
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
req := &backends.ChatRequest{
Model: "llama3.2:8b",
Messages: []backends.ChatMessage{
{Role: "user", Content: "Hello"},
},
}
resp, err := adapter.Chat(context.Background(), req)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if !resp.Done {
t.Error("Chat() response.Done = false, want true")
}
if resp.Message == nil || resp.Message.Content != "Hello! How can I help you?" {
t.Errorf("Chat() response content unexpected: %+v", resp.Message)
}
})
}
func TestAdapter_StreamChat(t *testing.T) {
t.Run("streaming chat", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/chat" && r.Method == "POST" {
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
// Check stream is true
if stream, ok := req["stream"].(bool); ok && !stream {
t.Error("Expected stream=true for streaming chat")
}
w.Header().Set("Content-Type", "application/x-ndjson")
flusher := w.(http.Flusher)
// Send streaming chunks
chunks := []map[string]interface{}{
{"model": "llama3.2:8b", "message": map[string]interface{}{"role": "assistant", "content": "Hello"}, "done": false},
{"model": "llama3.2:8b", "message": map[string]interface{}{"role": "assistant", "content": "!"}, "done": false},
{"model": "llama3.2:8b", "message": map[string]interface{}{"role": "assistant", "content": ""}, "done": true},
}
for _, chunk := range chunks {
data, _ := json.Marshal(chunk)
w.Write(append(data, '\n'))
flusher.Flush()
}
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
streaming := true
req := &backends.ChatRequest{
Model: "llama3.2:8b",
Messages: []backends.ChatMessage{
{Role: "user", Content: "Hello"},
},
Stream: &streaming,
}
chunkCh, err := adapter.StreamChat(context.Background(), req)
if err != nil {
t.Fatalf("StreamChat() error = %v", err)
}
var chunks []backends.ChatChunk
for chunk := range chunkCh {
chunks = append(chunks, chunk)
}
if len(chunks) != 3 {
t.Errorf("StreamChat() received %d chunks, want 3", len(chunks))
}
// Last chunk should be done
if !chunks[len(chunks)-1].Done {
t.Error("Last chunk should have Done=true")
}
})
t.Run("handles context cancellation", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/chat" {
w.Header().Set("Content-Type", "application/x-ndjson")
flusher := w.(http.Flusher)
// Send first chunk then wait
chunk := map[string]interface{}{"model": "llama3.2:8b", "message": map[string]interface{}{"role": "assistant", "content": "Starting..."}, "done": false}
data, _ := json.Marshal(chunk)
w.Write(append(data, '\n'))
flusher.Flush()
// Wait long enough for context to be cancelled
time.Sleep(2 * time.Second)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
streaming := true
req := &backends.ChatRequest{
Model: "llama3.2:8b",
Messages: []backends.ChatMessage{
{Role: "user", Content: "Hello"},
},
Stream: &streaming,
}
chunkCh, err := adapter.StreamChat(ctx, req)
if err != nil {
t.Fatalf("StreamChat() error = %v", err)
}
// Should receive at least one chunk before timeout
receivedChunks := 0
for range chunkCh {
receivedChunks++
}
if receivedChunks == 0 {
t.Error("Expected to receive at least one chunk before cancellation")
}
})
}
func TestAdapter_Info(t *testing.T) {
t.Run("connected server", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/api/version" {
json.NewEncoder(w).Encode(map[string]string{"version": "0.3.0"})
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
info := adapter.Info(context.Background())
if info.Type != backends.BackendTypeOllama {
t.Errorf("Info().Type = %v, want %v", info.Type, backends.BackendTypeOllama)
}
if info.Status != backends.BackendStatusConnected {
t.Errorf("Info().Status = %v, want %v", info.Status, backends.BackendStatusConnected)
}
if info.Version != "0.3.0" {
t.Errorf("Info().Version = %v, want %v", info.Version, "0.3.0")
}
})
t.Run("disconnected server", func(t *testing.T) {
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:19999",
})
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
info := adapter.Info(ctx)
if info.Status != backends.BackendStatusDisconnected {
t.Errorf("Info().Status = %v, want %v", info.Status, backends.BackendStatusDisconnected)
}
if info.Error == "" {
t.Error("Info().Error should be set for disconnected server")
}
})
}
func TestAdapter_ShowModel(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" && r.Method == "POST" {
var req map[string]string
json.NewDecoder(r.Body).Decode(&req)
resp := map[string]interface{}{
"modelfile": "FROM llama3.2:8b\nSYSTEM You are helpful.",
"template": "{{ .Prompt }}",
"system": "You are helpful.",
"details": map[string]interface{}{
"family": "llama",
"parameter_size": "8B",
"quantization_level": "Q4_K_M",
},
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
details, err := adapter.ShowModel(context.Background(), "llama3.2:8b")
if err != nil {
t.Fatalf("ShowModel() error = %v", err)
}
if details.Family != "llama" {
t.Errorf("ShowModel().Family = %q, want %q", details.Family, "llama")
}
if details.System != "You are helpful." {
t.Errorf("ShowModel().System = %q, want %q", details.System, "You are helpful.")
}
}
func TestAdapter_DeleteModel(t *testing.T) {
deleted := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/delete" && r.Method == "DELETE" {
deleted = true
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
err := adapter.DeleteModel(context.Background(), "test-model")
if err != nil {
t.Fatalf("DeleteModel() error = %v", err)
}
if !deleted {
t.Error("DeleteModel() did not call the delete endpoint")
}
}
func TestAdapter_CopyModel(t *testing.T) {
copied := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/copy" && r.Method == "POST" {
var req map[string]string
json.NewDecoder(r.Body).Decode(&req)
if req["source"] == "source-model" && req["destination"] == "dest-model" {
copied = true
}
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
err := adapter.CopyModel(context.Background(), "source-model", "dest-model")
if err != nil {
t.Fatalf("CopyModel() error = %v", err)
}
if !copied {
t.Error("CopyModel() did not call the copy endpoint with correct params")
}
}
func TestAdapter_Embed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/embed" && r.Method == "POST" {
resp := map[string]interface{}{
"embeddings": [][]float64{
{0.1, 0.2, 0.3},
{0.4, 0.5, 0.6},
},
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: server.URL,
})
embeddings, err := adapter.Embed(context.Background(), "nomic-embed-text", []string{"hello", "world"})
if err != nil {
t.Fatalf("Embed() error = %v", err)
}
if len(embeddings) != 2 {
t.Errorf("Embed() returned %d embeddings, want 2", len(embeddings))
}
if len(embeddings[0]) != 3 {
t.Errorf("First embedding has %d dimensions, want 3", len(embeddings[0]))
}
}
func TestNewAdapter_Validation(t *testing.T) {
t.Run("invalid URL", func(t *testing.T) {
_, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "not-a-url",
})
if err == nil {
t.Error("NewAdapter() should fail with invalid URL")
}
})
t.Run("wrong backend type", func(t *testing.T) {
_, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: "http://localhost:11434",
})
if err == nil {
t.Error("NewAdapter() should fail with wrong backend type")
}
})
t.Run("valid config", func(t *testing.T) {
adapter, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:11434",
})
if err != nil {
t.Errorf("NewAdapter() error = %v", err)
}
if adapter == nil {
t.Error("NewAdapter() returned nil adapter")
}
})
}

View File

@@ -0,0 +1,503 @@
package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"vessel-backend/internal/backends"
)
// Adapter implements the LLMBackend interface for OpenAI-compatible APIs.
// This includes llama.cpp server and LM Studio.
type Adapter struct {
config backends.BackendConfig
httpClient *http.Client
baseURL *url.URL
}
// Ensure Adapter implements required interfaces
var (
_ backends.LLMBackend = (*Adapter)(nil)
_ backends.EmbeddingProvider = (*Adapter)(nil)
)
// NewAdapter creates a new OpenAI-compatible backend adapter
func NewAdapter(config backends.BackendConfig) (*Adapter, error) {
if config.Type != backends.BackendTypeLlamaCpp && config.Type != backends.BackendTypeLMStudio {
return nil, fmt.Errorf("invalid backend type: expected %s or %s, got %s",
backends.BackendTypeLlamaCpp, backends.BackendTypeLMStudio, config.Type)
}
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
baseURL, err := url.Parse(config.BaseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
return &Adapter{
config: config,
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
}
// Type returns the backend type
func (a *Adapter) Type() backends.BackendType {
return a.config.Type
}
// Config returns the backend configuration
func (a *Adapter) Config() backends.BackendConfig {
return a.config
}
// Capabilities returns what features this backend supports
func (a *Adapter) Capabilities() backends.BackendCapabilities {
if a.config.Type == backends.BackendTypeLlamaCpp {
return backends.LlamaCppCapabilities()
}
return backends.LMStudioCapabilities()
}
// HealthCheck verifies the backend is reachable
func (a *Adapter) HealthCheck(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/v1/models", nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to reach backend: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("backend returned status %d", resp.StatusCode)
}
return nil
}
// openaiModelsResponse represents the response from /v1/models
type openaiModelsResponse struct {
Data []openaiModel `json:"data"`
}
type openaiModel struct {
ID string `json:"id"`
Object string `json:"object"`
OwnedBy string `json:"owned_by"`
Created int64 `json:"created"`
}
// ListModels returns all models available from this backend
func (a *Adapter) ListModels(ctx context.Context) ([]backends.Model, error) {
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/v1/models", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to list models: %w", err)
}
defer resp.Body.Close()
var listResp openaiModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
models := make([]backends.Model, len(listResp.Data))
for i, m := range listResp.Data {
models[i] = backends.Model{
ID: m.ID,
Name: m.ID,
}
}
return models, nil
}
// Chat sends a non-streaming chat request
func (a *Adapter) Chat(ctx context.Context, req *backends.ChatRequest) (*backends.ChatChunk, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
openaiReq := a.convertChatRequest(req)
openaiReq["stream"] = false
body, err := json.Marshal(openaiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("chat request failed: %w", err)
}
defer resp.Body.Close()
var openaiResp openaiChatResponse
if err := json.NewDecoder(resp.Body).Decode(&openaiResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return a.convertChatResponse(&openaiResp), nil
}
// StreamChat sends a streaming chat request
func (a *Adapter) StreamChat(ctx context.Context, req *backends.ChatRequest) (<-chan backends.ChatChunk, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
openaiReq := a.convertChatRequest(req)
openaiReq["stream"] = true
body, err := json.Marshal(openaiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
// Use a client without timeout for streaming
client := &http.Client{}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("chat request failed: %w", err)
}
chunkCh := make(chan backends.ChatChunk)
go func() {
defer close(chunkCh)
defer resp.Body.Close()
a.parseSSEStream(ctx, resp.Body, chunkCh)
}()
return chunkCh, nil
}
// parseSSEStream parses Server-Sent Events and emits ChatChunks
func (a *Adapter) parseSSEStream(ctx context.Context, body io.Reader, chunkCh chan<- backends.ChatChunk) {
scanner := bufio.NewScanner(body)
// Track accumulated tool call arguments
toolCallArgs := make(map[int]string)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
}
line := scanner.Text()
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, ":") {
continue
}
// Parse SSE data line
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
// Check for stream end
if data == "[DONE]" {
chunkCh <- backends.ChatChunk{Done: true}
return
}
var streamResp openaiStreamResponse
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
chunkCh <- backends.ChatChunk{Error: fmt.Sprintf("failed to parse SSE data: %v", err)}
continue
}
chunk := a.convertStreamResponse(&streamResp, toolCallArgs)
chunkCh <- chunk
if chunk.Done {
return
}
}
if err := scanner.Err(); err != nil && ctx.Err() == nil {
chunkCh <- backends.ChatChunk{Error: fmt.Sprintf("stream error: %v", err)}
}
}
// Info returns detailed information about the backend
func (a *Adapter) Info(ctx context.Context) backends.BackendInfo {
info := backends.BackendInfo{
Type: a.config.Type,
BaseURL: a.config.BaseURL,
Capabilities: a.Capabilities(),
}
// Try to reach the models endpoint
if err := a.HealthCheck(ctx); err != nil {
info.Status = backends.BackendStatusDisconnected
info.Error = err.Error()
return info
}
info.Status = backends.BackendStatusConnected
return info
}
// Embed generates embeddings for the given input
func (a *Adapter) Embed(ctx context.Context, model string, input []string) ([][]float64, error) {
body, err := json.Marshal(map[string]interface{}{
"model": model,
"input": input,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/v1/embeddings", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("embed request failed: %w", err)
}
defer resp.Body.Close()
var embedResp struct {
Data []struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&embedResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
embeddings := make([][]float64, len(embedResp.Data))
for _, d := range embedResp.Data {
embeddings[d.Index] = d.Embedding
}
return embeddings, nil
}
// OpenAI API response types
type openaiChatResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []openaiChoice `json:"choices"`
Usage *openaiUsage `json:"usage,omitempty"`
}
type openaiChoice struct {
Index int `json:"index"`
Message *openaiMessage `json:"message,omitempty"`
Delta *openaiMessage `json:"delta,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
}
type openaiMessage struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
}
type openaiToolCall struct {
ID string `json:"id,omitempty"`
Index int `json:"index,omitempty"`
Type string `json:"type,omitempty"`
Function struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
} `json:"function"`
}
type openaiUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type openaiStreamResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []openaiChoice `json:"choices"`
}
// convertChatRequest converts a backends.ChatRequest to OpenAI format
func (a *Adapter) convertChatRequest(req *backends.ChatRequest) map[string]interface{} {
messages := make([]map[string]interface{}, len(req.Messages))
for i, msg := range req.Messages {
m := map[string]interface{}{
"role": msg.Role,
"content": msg.Content,
}
if msg.Name != "" {
m["name"] = msg.Name
}
if msg.ToolCallID != "" {
m["tool_call_id"] = msg.ToolCallID
}
messages[i] = m
}
openaiReq := map[string]interface{}{
"model": req.Model,
"messages": messages,
}
// Add optional parameters
if req.Temperature != nil {
openaiReq["temperature"] = *req.Temperature
}
if req.TopP != nil {
openaiReq["top_p"] = *req.TopP
}
if req.MaxTokens != nil {
openaiReq["max_tokens"] = *req.MaxTokens
}
if len(req.Tools) > 0 {
openaiReq["tools"] = req.Tools
}
return openaiReq
}
// convertChatResponse converts an OpenAI response to backends.ChatChunk
func (a *Adapter) convertChatResponse(resp *openaiChatResponse) *backends.ChatChunk {
chunk := &backends.ChatChunk{
Model: resp.Model,
Done: true,
}
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
if choice.Message != nil {
msg := &backends.ChatMessage{
Role: choice.Message.Role,
Content: choice.Message.Content,
}
// Convert tool calls
for _, tc := range choice.Message.ToolCalls {
msg.ToolCalls = append(msg.ToolCalls, backends.ToolCall{
ID: tc.ID,
Type: tc.Type,
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
chunk.Message = msg
}
if choice.FinishReason != "" {
chunk.DoneReason = choice.FinishReason
}
}
if resp.Usage != nil {
chunk.PromptEvalCount = resp.Usage.PromptTokens
chunk.EvalCount = resp.Usage.CompletionTokens
}
return chunk
}
// convertStreamResponse converts an OpenAI stream response to backends.ChatChunk
func (a *Adapter) convertStreamResponse(resp *openaiStreamResponse, toolCallArgs map[int]string) backends.ChatChunk {
chunk := backends.ChatChunk{
Model: resp.Model,
}
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
if choice.FinishReason != "" {
chunk.Done = true
chunk.DoneReason = choice.FinishReason
}
if choice.Delta != nil {
msg := &backends.ChatMessage{
Role: choice.Delta.Role,
Content: choice.Delta.Content,
}
// Handle streaming tool calls
for _, tc := range choice.Delta.ToolCalls {
// Accumulate arguments
if tc.Function.Arguments != "" {
toolCallArgs[tc.Index] += tc.Function.Arguments
}
// Only add tool call when we have the initial info
if tc.ID != "" || tc.Function.Name != "" {
msg.ToolCalls = append(msg.ToolCalls, backends.ToolCall{
ID: tc.ID,
Type: tc.Type,
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: tc.Function.Name,
Arguments: toolCallArgs[tc.Index],
},
})
}
}
chunk.Message = msg
}
}
return chunk
}

View File

@@ -0,0 +1,594 @@
package openai
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"vessel-backend/internal/backends"
)
func TestAdapter_Type(t *testing.T) {
tests := []struct {
name string
backendType backends.BackendType
expectedType backends.BackendType
}{
{"llamacpp type", backends.BackendTypeLlamaCpp, backends.BackendTypeLlamaCpp},
{"lmstudio type", backends.BackendTypeLMStudio, backends.BackendTypeLMStudio},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
adapter, _ := NewAdapter(backends.BackendConfig{
Type: tt.backendType,
BaseURL: "http://localhost:8081",
})
if adapter.Type() != tt.expectedType {
t.Errorf("Type() = %v, want %v", adapter.Type(), tt.expectedType)
}
})
}
}
func TestAdapter_Config(t *testing.T) {
cfg := backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: "http://localhost:8081",
Enabled: true,
}
adapter, _ := NewAdapter(cfg)
got := adapter.Config()
if got.Type != cfg.Type {
t.Errorf("Config().Type = %v, want %v", got.Type, cfg.Type)
}
if got.BaseURL != cfg.BaseURL {
t.Errorf("Config().BaseURL = %v, want %v", got.BaseURL, cfg.BaseURL)
}
}
func TestAdapter_Capabilities(t *testing.T) {
t.Run("llamacpp capabilities", func(t *testing.T) {
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: "http://localhost:8081",
})
caps := adapter.Capabilities()
if !caps.CanListModels {
t.Error("llama.cpp adapter should support listing models")
}
if caps.CanPullModels {
t.Error("llama.cpp adapter should NOT support pulling models")
}
if caps.CanDeleteModels {
t.Error("llama.cpp adapter should NOT support deleting models")
}
if caps.CanCreateModels {
t.Error("llama.cpp adapter should NOT support creating models")
}
if !caps.CanStreamChat {
t.Error("llama.cpp adapter should support streaming chat")
}
if !caps.CanEmbed {
t.Error("llama.cpp adapter should support embeddings")
}
})
t.Run("lmstudio capabilities", func(t *testing.T) {
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLMStudio,
BaseURL: "http://localhost:1234",
})
caps := adapter.Capabilities()
if !caps.CanListModels {
t.Error("LM Studio adapter should support listing models")
}
if caps.CanPullModels {
t.Error("LM Studio adapter should NOT support pulling models")
}
})
}
func TestAdapter_HealthCheck(t *testing.T) {
t.Run("healthy server", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]string{{"id": "llama3.2:8b"}},
})
}
}))
defer server.Close()
adapter, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: server.URL,
})
if err != nil {
t.Fatalf("Failed to create adapter: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := adapter.HealthCheck(ctx); err != nil {
t.Errorf("HealthCheck() error = %v, want nil", err)
}
})
t.Run("unreachable server", func(t *testing.T) {
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: "http://localhost:19999",
})
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if err := adapter.HealthCheck(ctx); err == nil {
t.Error("HealthCheck() expected error for unreachable server")
}
})
}
func TestAdapter_ListModels(t *testing.T) {
t.Run("returns model list", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
resp := map[string]interface{}{
"data": []map[string]interface{}{
{
"id": "llama3.2-8b-instruct",
"object": "model",
"owned_by": "local",
"created": 1700000000,
},
{
"id": "mistral-7b-v0.2",
"object": "model",
"owned_by": "local",
"created": 1700000001,
},
},
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: server.URL,
})
ctx := context.Background()
models, err := adapter.ListModels(ctx)
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
if len(models) != 2 {
t.Errorf("ListModels() returned %d models, want 2", len(models))
}
if models[0].ID != "llama3.2-8b-instruct" {
t.Errorf("First model ID = %q, want %q", models[0].ID, "llama3.2-8b-instruct")
}
})
t.Run("handles empty model list", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
resp := map[string]interface{}{
"data": []map[string]interface{}{},
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: server.URL,
})
models, err := adapter.ListModels(context.Background())
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
if len(models) != 0 {
t.Errorf("ListModels() returned %d models, want 0", len(models))
}
})
}
func TestAdapter_Chat(t *testing.T) {
t.Run("non-streaming chat", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/chat/completions" && r.Method == "POST" {
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
// Check stream is false
if stream, ok := req["stream"].(bool); ok && stream {
t.Error("Expected stream=false for non-streaming chat")
}
resp := map[string]interface{}{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1700000000,
"model": "llama3.2:8b",
"choices": []map[string]interface{}{
{
"index": 0,
"message": map[string]interface{}{
"role": "assistant",
"content": "Hello! How can I help you?",
},
"finish_reason": "stop",
},
},
"usage": map[string]int{
"prompt_tokens": 10,
"completion_tokens": 8,
"total_tokens": 18,
},
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: server.URL,
})
req := &backends.ChatRequest{
Model: "llama3.2:8b",
Messages: []backends.ChatMessage{
{Role: "user", Content: "Hello"},
},
}
resp, err := adapter.Chat(context.Background(), req)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if !resp.Done {
t.Error("Chat() response.Done = false, want true")
}
if resp.Message == nil || resp.Message.Content != "Hello! How can I help you?" {
t.Errorf("Chat() response content unexpected: %+v", resp.Message)
}
})
}
func TestAdapter_StreamChat(t *testing.T) {
t.Run("streaming chat with SSE", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/chat/completions" && r.Method == "POST" {
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
// Check stream is true
if stream, ok := req["stream"].(bool); !ok || !stream {
t.Error("Expected stream=true for streaming chat")
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher := w.(http.Flusher)
// Send SSE chunks
chunks := []string{
`{"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant","content":"Hello"}}]}`,
`{"id":"chatcmpl-1","choices":[{"delta":{"content":"!"}}]}`,
`{"id":"chatcmpl-1","choices":[{"delta":{},"finish_reason":"stop"}]}`,
}
for _, chunk := range chunks {
fmt.Fprintf(w, "data: %s\n\n", chunk)
flusher.Flush()
}
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: server.URL,
})
streaming := true
req := &backends.ChatRequest{
Model: "llama3.2:8b",
Messages: []backends.ChatMessage{
{Role: "user", Content: "Hello"},
},
Stream: &streaming,
}
chunkCh, err := adapter.StreamChat(context.Background(), req)
if err != nil {
t.Fatalf("StreamChat() error = %v", err)
}
var chunks []backends.ChatChunk
for chunk := range chunkCh {
chunks = append(chunks, chunk)
}
if len(chunks) < 2 {
t.Errorf("StreamChat() received %d chunks, want at least 2", len(chunks))
}
// Last chunk should be done
if !chunks[len(chunks)-1].Done {
t.Error("Last chunk should have Done=true")
}
})
t.Run("handles context cancellation", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/chat/completions" {
w.Header().Set("Content-Type", "text/event-stream")
flusher := w.(http.Flusher)
// Send first chunk then wait
fmt.Fprintf(w, "data: %s\n\n", `{"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant","content":"Starting..."}}]}`)
flusher.Flush()
// Wait long enough for context to be cancelled
time.Sleep(2 * time.Second)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: server.URL,
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
streaming := true
req := &backends.ChatRequest{
Model: "llama3.2:8b",
Messages: []backends.ChatMessage{
{Role: "user", Content: "Hello"},
},
Stream: &streaming,
}
chunkCh, err := adapter.StreamChat(ctx, req)
if err != nil {
t.Fatalf("StreamChat() error = %v", err)
}
// Should receive at least one chunk before timeout
receivedChunks := 0
for range chunkCh {
receivedChunks++
}
if receivedChunks == 0 {
t.Error("Expected to receive at least one chunk before cancellation")
}
})
}
func TestAdapter_Info(t *testing.T) {
t.Run("connected server", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]string{{"id": "llama3.2:8b"}},
})
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: server.URL,
})
info := adapter.Info(context.Background())
if info.Type != backends.BackendTypeLlamaCpp {
t.Errorf("Info().Type = %v, want %v", info.Type, backends.BackendTypeLlamaCpp)
}
if info.Status != backends.BackendStatusConnected {
t.Errorf("Info().Status = %v, want %v", info.Status, backends.BackendStatusConnected)
}
})
t.Run("disconnected server", func(t *testing.T) {
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: "http://localhost:19999",
})
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
info := adapter.Info(ctx)
if info.Status != backends.BackendStatusDisconnected {
t.Errorf("Info().Status = %v, want %v", info.Status, backends.BackendStatusDisconnected)
}
if info.Error == "" {
t.Error("Info().Error should be set for disconnected server")
}
})
}
func TestAdapter_Embed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/embeddings" && r.Method == "POST" {
resp := map[string]interface{}{
"data": []map[string]interface{}{
{"embedding": []float64{0.1, 0.2, 0.3}, "index": 0},
{"embedding": []float64{0.4, 0.5, 0.6}, "index": 1},
},
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: server.URL,
})
embeddings, err := adapter.Embed(context.Background(), "nomic-embed-text", []string{"hello", "world"})
if err != nil {
t.Fatalf("Embed() error = %v", err)
}
if len(embeddings) != 2 {
t.Errorf("Embed() returned %d embeddings, want 2", len(embeddings))
}
if len(embeddings[0]) != 3 {
t.Errorf("First embedding has %d dimensions, want 3", len(embeddings[0]))
}
}
func TestNewAdapter_Validation(t *testing.T) {
t.Run("invalid URL", func(t *testing.T) {
_, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: "not-a-url",
})
if err == nil {
t.Error("NewAdapter() should fail with invalid URL")
}
})
t.Run("wrong backend type", func(t *testing.T) {
_, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeOllama,
BaseURL: "http://localhost:8081",
})
if err == nil {
t.Error("NewAdapter() should fail with Ollama backend type")
}
})
t.Run("valid llamacpp config", func(t *testing.T) {
adapter, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: "http://localhost:8081",
})
if err != nil {
t.Errorf("NewAdapter() error = %v", err)
}
if adapter == nil {
t.Error("NewAdapter() returned nil adapter")
}
})
t.Run("valid lmstudio config", func(t *testing.T) {
adapter, err := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLMStudio,
BaseURL: "http://localhost:1234",
})
if err != nil {
t.Errorf("NewAdapter() error = %v", err)
}
if adapter == nil {
t.Error("NewAdapter() returned nil adapter")
}
})
}
func TestAdapter_ToolCalls(t *testing.T) {
t.Run("streaming with tool calls", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/chat/completions" {
w.Header().Set("Content-Type", "text/event-stream")
flusher := w.(http.Flusher)
// Send tool call chunks
chunks := []string{
`{"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}`,
`{"id":"chatcmpl-1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"location\":"}}]}}]}`,
`{"id":"chatcmpl-1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"Tokyo\"}"}}]}}]}`,
`{"id":"chatcmpl-1","choices":[{"delta":{},"finish_reason":"tool_calls"}]}`,
}
for _, chunk := range chunks {
fmt.Fprintf(w, "data: %s\n\n", chunk)
flusher.Flush()
}
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
}
}))
defer server.Close()
adapter, _ := NewAdapter(backends.BackendConfig{
Type: backends.BackendTypeLlamaCpp,
BaseURL: server.URL,
})
streaming := true
req := &backends.ChatRequest{
Model: "llama3.2:8b",
Messages: []backends.ChatMessage{
{Role: "user", Content: "What's the weather in Tokyo?"},
},
Stream: &streaming,
Tools: []backends.Tool{
{
Type: "function",
Function: struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}{
Name: "get_weather",
Description: "Get weather for a location",
},
},
},
}
chunkCh, err := adapter.StreamChat(context.Background(), req)
if err != nil {
t.Fatalf("StreamChat() error = %v", err)
}
var lastChunk backends.ChatChunk
for chunk := range chunkCh {
lastChunk = chunk
}
if !lastChunk.Done {
t.Error("Last chunk should have Done=true")
}
})
}

View File

@@ -0,0 +1,242 @@
package backends
import (
"context"
"fmt"
"net/http"
"sync"
"time"
)
// Registry manages multiple LLM backend instances
type Registry struct {
mu sync.RWMutex
backends map[BackendType]LLMBackend
active BackendType
}
// NewRegistry creates a new backend registry
func NewRegistry() *Registry {
return &Registry{
backends: make(map[BackendType]LLMBackend),
}
}
// Register adds a backend to the registry
func (r *Registry) Register(backend LLMBackend) error {
r.mu.Lock()
defer r.mu.Unlock()
bt := backend.Type()
if _, exists := r.backends[bt]; exists {
return fmt.Errorf("backend %q already registered", bt)
}
r.backends[bt] = backend
return nil
}
// Unregister removes a backend from the registry
func (r *Registry) Unregister(backendType BackendType) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.backends[backendType]; !exists {
return fmt.Errorf("backend %q not registered", backendType)
}
delete(r.backends, backendType)
// Clear active if it was the unregistered backend
if r.active == backendType {
r.active = ""
}
return nil
}
// Get retrieves a backend by type
func (r *Registry) Get(backendType BackendType) (LLMBackend, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
backend, ok := r.backends[backendType]
return backend, ok
}
// SetActive sets the active backend
func (r *Registry) SetActive(backendType BackendType) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.backends[backendType]; !exists {
return fmt.Errorf("backend %q not registered", backendType)
}
r.active = backendType
return nil
}
// Active returns the currently active backend
func (r *Registry) Active() LLMBackend {
r.mu.RLock()
defer r.mu.RUnlock()
if r.active == "" {
return nil
}
return r.backends[r.active]
}
// ActiveType returns the type of the currently active backend
func (r *Registry) ActiveType() BackendType {
r.mu.RLock()
defer r.mu.RUnlock()
return r.active
}
// Backends returns all registered backend types
func (r *Registry) Backends() []BackendType {
r.mu.RLock()
defer r.mu.RUnlock()
types := make([]BackendType, 0, len(r.backends))
for bt := range r.backends {
types = append(types, bt)
}
return types
}
// AllInfo returns information about all registered backends
func (r *Registry) AllInfo(ctx context.Context) []BackendInfo {
r.mu.RLock()
defer r.mu.RUnlock()
infos := make([]BackendInfo, 0, len(r.backends))
for _, backend := range r.backends {
infos = append(infos, backend.Info(ctx))
}
return infos
}
// DiscoveryEndpoint represents a potential backend endpoint to probe
type DiscoveryEndpoint struct {
Type BackendType
BaseURL string
}
// DiscoveryResult represents the result of probing an endpoint
type DiscoveryResult struct {
Type BackendType `json:"type"`
BaseURL string `json:"baseUrl"`
Available bool `json:"available"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// Discover probes the given endpoints to find available backends
func (r *Registry) Discover(ctx context.Context, endpoints []DiscoveryEndpoint) []DiscoveryResult {
results := make([]DiscoveryResult, len(endpoints))
var wg sync.WaitGroup
for i, endpoint := range endpoints {
wg.Add(1)
go func(idx int, ep DiscoveryEndpoint) {
defer wg.Done()
results[idx] = probeEndpoint(ctx, ep)
}(i, endpoint)
}
wg.Wait()
return results
}
// probeEndpoint checks if a backend is available at the given endpoint
func probeEndpoint(ctx context.Context, endpoint DiscoveryEndpoint) DiscoveryResult {
result := DiscoveryResult{
Type: endpoint.Type,
BaseURL: endpoint.BaseURL,
}
client := &http.Client{
Timeout: 3 * time.Second,
}
// Determine probe path based on backend type
var probePath string
switch endpoint.Type {
case BackendTypeOllama:
probePath = "/api/version"
case BackendTypeLlamaCpp, BackendTypeLMStudio:
probePath = "/v1/models"
default:
probePath = "/health"
}
req, err := http.NewRequestWithContext(ctx, "GET", endpoint.BaseURL+probePath, nil)
if err != nil {
result.Error = err.Error()
return result
}
resp, err := client.Do(req)
if err != nil {
result.Error = err.Error()
return result
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
result.Available = true
} else {
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return result
}
// DefaultDiscoveryEndpoints returns the default endpoints to probe
func DefaultDiscoveryEndpoints() []DiscoveryEndpoint {
return []DiscoveryEndpoint{
{Type: BackendTypeOllama, BaseURL: "http://localhost:11434"},
{Type: BackendTypeLlamaCpp, BaseURL: "http://localhost:8081"},
{Type: BackendTypeLlamaCpp, BaseURL: "http://localhost:8080"},
{Type: BackendTypeLMStudio, BaseURL: "http://localhost:1234"},
}
}
// DiscoverAndRegister probes endpoints and registers available backends
func (r *Registry) DiscoverAndRegister(ctx context.Context, endpoints []DiscoveryEndpoint, adapterFactory AdapterFactory) []DiscoveryResult {
results := r.Discover(ctx, endpoints)
for _, result := range results {
if !result.Available {
continue
}
// Skip if already registered
if _, exists := r.Get(result.Type); exists {
continue
}
config := BackendConfig{
Type: result.Type,
BaseURL: result.BaseURL,
Enabled: true,
}
adapter, err := adapterFactory(config)
if err != nil {
continue
}
r.Register(adapter)
}
return results
}
// AdapterFactory creates an LLMBackend from a config
type AdapterFactory func(config BackendConfig) (LLMBackend, error)

View File

@@ -0,0 +1,352 @@
package backends
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewRegistry(t *testing.T) {
registry := NewRegistry()
if registry == nil {
t.Fatal("NewRegistry() returned nil")
}
if len(registry.Backends()) != 0 {
t.Errorf("New registry should have no backends, got %d", len(registry.Backends()))
}
if registry.Active() != nil {
t.Error("New registry should have no active backend")
}
}
func TestRegistry_Register(t *testing.T) {
registry := NewRegistry()
// Create a mock backend
mock := &mockBackend{
backendType: BackendTypeOllama,
config: BackendConfig{
Type: BackendTypeOllama,
BaseURL: "http://localhost:11434",
},
}
err := registry.Register(mock)
if err != nil {
t.Fatalf("Register() error = %v", err)
}
if len(registry.Backends()) != 1 {
t.Errorf("Registry should have 1 backend, got %d", len(registry.Backends()))
}
// Should not allow duplicate registration
err = registry.Register(mock)
if err == nil {
t.Error("Register() should fail for duplicate backend type")
}
}
func TestRegistry_Get(t *testing.T) {
registry := NewRegistry()
mock := &mockBackend{
backendType: BackendTypeOllama,
config: BackendConfig{
Type: BackendTypeOllama,
BaseURL: "http://localhost:11434",
},
}
registry.Register(mock)
t.Run("existing backend", func(t *testing.T) {
backend, ok := registry.Get(BackendTypeOllama)
if !ok {
t.Error("Get() should return ok=true for registered backend")
}
if backend != mock {
t.Error("Get() returned wrong backend")
}
})
t.Run("non-existing backend", func(t *testing.T) {
_, ok := registry.Get(BackendTypeLlamaCpp)
if ok {
t.Error("Get() should return ok=false for unregistered backend")
}
})
}
func TestRegistry_SetActive(t *testing.T) {
registry := NewRegistry()
mock := &mockBackend{
backendType: BackendTypeOllama,
config: BackendConfig{
Type: BackendTypeOllama,
BaseURL: "http://localhost:11434",
},
}
registry.Register(mock)
t.Run("set registered backend as active", func(t *testing.T) {
err := registry.SetActive(BackendTypeOllama)
if err != nil {
t.Errorf("SetActive() error = %v", err)
}
active := registry.Active()
if active == nil {
t.Fatal("Active() returned nil after SetActive()")
}
if active.Type() != BackendTypeOllama {
t.Errorf("Active().Type() = %v, want %v", active.Type(), BackendTypeOllama)
}
})
t.Run("set unregistered backend as active", func(t *testing.T) {
err := registry.SetActive(BackendTypeLlamaCpp)
if err == nil {
t.Error("SetActive() should fail for unregistered backend")
}
})
}
func TestRegistry_ActiveType(t *testing.T) {
registry := NewRegistry()
t.Run("no active backend", func(t *testing.T) {
activeType := registry.ActiveType()
if activeType != "" {
t.Errorf("ActiveType() = %q, want empty string", activeType)
}
})
t.Run("with active backend", func(t *testing.T) {
mock := &mockBackend{backendType: BackendTypeOllama}
registry.Register(mock)
registry.SetActive(BackendTypeOllama)
activeType := registry.ActiveType()
if activeType != BackendTypeOllama {
t.Errorf("ActiveType() = %v, want %v", activeType, BackendTypeOllama)
}
})
}
func TestRegistry_Unregister(t *testing.T) {
registry := NewRegistry()
mock := &mockBackend{backendType: BackendTypeOllama}
registry.Register(mock)
registry.SetActive(BackendTypeOllama)
err := registry.Unregister(BackendTypeOllama)
if err != nil {
t.Errorf("Unregister() error = %v", err)
}
if len(registry.Backends()) != 0 {
t.Error("Registry should have no backends after unregister")
}
if registry.Active() != nil {
t.Error("Active backend should be nil after unregistering it")
}
}
func TestRegistry_AllInfo(t *testing.T) {
registry := NewRegistry()
mock1 := &mockBackend{
backendType: BackendTypeOllama,
config: BackendConfig{Type: BackendTypeOllama, BaseURL: "http://localhost:11434"},
info: BackendInfo{
Type: BackendTypeOllama,
Status: BackendStatusConnected,
Version: "0.1.0",
},
}
mock2 := &mockBackend{
backendType: BackendTypeLlamaCpp,
config: BackendConfig{Type: BackendTypeLlamaCpp, BaseURL: "http://localhost:8081"},
info: BackendInfo{
Type: BackendTypeLlamaCpp,
Status: BackendStatusDisconnected,
},
}
registry.Register(mock1)
registry.Register(mock2)
registry.SetActive(BackendTypeOllama)
infos := registry.AllInfo(context.Background())
if len(infos) != 2 {
t.Errorf("AllInfo() returned %d infos, want 2", len(infos))
}
// Find the active one
var foundActive bool
for _, info := range infos {
if info.Type == BackendTypeOllama {
foundActive = true
}
}
if !foundActive {
t.Error("AllInfo() did not include ollama backend info")
}
}
func TestRegistry_Discover(t *testing.T) {
// Create test servers for each backend type
ollamaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/version" || r.URL.Path == "/" {
json.NewEncoder(w).Encode(map[string]string{"version": "0.3.0"})
}
}))
defer ollamaServer.Close()
llamacppServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]string{{"id": "llama3.2:8b"}},
})
}
if r.URL.Path == "/health" {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
}))
defer llamacppServer.Close()
registry := NewRegistry()
// Configure discovery endpoints
endpoints := []DiscoveryEndpoint{
{Type: BackendTypeOllama, BaseURL: ollamaServer.URL},
{Type: BackendTypeLlamaCpp, BaseURL: llamacppServer.URL},
{Type: BackendTypeLMStudio, BaseURL: "http://localhost:19999"}, // Not running
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results := registry.Discover(ctx, endpoints)
if len(results) != 3 {
t.Errorf("Discover() returned %d results, want 3", len(results))
}
// Check Ollama was discovered
var ollamaResult *DiscoveryResult
for i := range results {
if results[i].Type == BackendTypeOllama {
ollamaResult = &results[i]
break
}
}
if ollamaResult == nil {
t.Fatal("Ollama not found in discovery results")
}
if !ollamaResult.Available {
t.Errorf("Ollama should be available, error: %s", ollamaResult.Error)
}
// Check LM Studio was not discovered
var lmstudioResult *DiscoveryResult
for i := range results {
if results[i].Type == BackendTypeLMStudio {
lmstudioResult = &results[i]
break
}
}
if lmstudioResult == nil {
t.Fatal("LM Studio not found in discovery results")
}
if lmstudioResult.Available {
t.Error("LM Studio should NOT be available")
}
}
func TestRegistry_DefaultEndpoints(t *testing.T) {
endpoints := DefaultDiscoveryEndpoints()
if len(endpoints) < 3 {
t.Errorf("DefaultDiscoveryEndpoints() returned %d endpoints, want at least 3", len(endpoints))
}
// Check that all expected types are present
types := make(map[BackendType]bool)
for _, e := range endpoints {
types[e.Type] = true
}
if !types[BackendTypeOllama] {
t.Error("DefaultDiscoveryEndpoints() missing Ollama")
}
if !types[BackendTypeLlamaCpp] {
t.Error("DefaultDiscoveryEndpoints() missing llama.cpp")
}
if !types[BackendTypeLMStudio] {
t.Error("DefaultDiscoveryEndpoints() missing LM Studio")
}
}
// mockBackend implements LLMBackend for testing
type mockBackend struct {
backendType BackendType
config BackendConfig
info BackendInfo
healthErr error
models []Model
}
func (m *mockBackend) Type() BackendType {
return m.backendType
}
func (m *mockBackend) Config() BackendConfig {
return m.config
}
func (m *mockBackend) HealthCheck(ctx context.Context) error {
return m.healthErr
}
func (m *mockBackend) ListModels(ctx context.Context) ([]Model, error) {
return m.models, nil
}
func (m *mockBackend) StreamChat(ctx context.Context, req *ChatRequest) (<-chan ChatChunk, error) {
ch := make(chan ChatChunk)
close(ch)
return ch, nil
}
func (m *mockBackend) Chat(ctx context.Context, req *ChatRequest) (*ChatChunk, error) {
return &ChatChunk{Done: true}, nil
}
func (m *mockBackend) Capabilities() BackendCapabilities {
return OllamaCapabilities()
}
func (m *mockBackend) Info(ctx context.Context) BackendInfo {
if m.info.Type != "" {
return m.info
}
return BackendInfo{
Type: m.backendType,
BaseURL: m.config.BaseURL,
Status: BackendStatusConnected,
Capabilities: m.Capabilities(),
}
}

View File

@@ -0,0 +1,245 @@
package backends
import (
"errors"
"fmt"
"net/url"
"strings"
)
// BackendType identifies the type of LLM backend
type BackendType string
const (
BackendTypeOllama BackendType = "ollama"
BackendTypeLlamaCpp BackendType = "llamacpp"
BackendTypeLMStudio BackendType = "lmstudio"
)
// String returns the string representation of the backend type
func (bt BackendType) String() string {
return string(bt)
}
// ParseBackendType parses a string into a BackendType
func ParseBackendType(s string) (BackendType, error) {
switch strings.ToLower(s) {
case "ollama":
return BackendTypeOllama, nil
case "llamacpp", "llama.cpp", "llama-cpp":
return BackendTypeLlamaCpp, nil
case "lmstudio", "lm-studio", "lm_studio":
return BackendTypeLMStudio, nil
default:
return "", fmt.Errorf("unknown backend type: %q", s)
}
}
// BackendCapabilities describes what features a backend supports
type BackendCapabilities struct {
CanListModels bool `json:"canListModels"`
CanPullModels bool `json:"canPullModels"`
CanDeleteModels bool `json:"canDeleteModels"`
CanCreateModels bool `json:"canCreateModels"`
CanStreamChat bool `json:"canStreamChat"`
CanEmbed bool `json:"canEmbed"`
}
// OllamaCapabilities returns the capabilities for Ollama backend
func OllamaCapabilities() BackendCapabilities {
return BackendCapabilities{
CanListModels: true,
CanPullModels: true,
CanDeleteModels: true,
CanCreateModels: true,
CanStreamChat: true,
CanEmbed: true,
}
}
// LlamaCppCapabilities returns the capabilities for llama.cpp backend
func LlamaCppCapabilities() BackendCapabilities {
return BackendCapabilities{
CanListModels: true,
CanPullModels: false,
CanDeleteModels: false,
CanCreateModels: false,
CanStreamChat: true,
CanEmbed: true,
}
}
// LMStudioCapabilities returns the capabilities for LM Studio backend
func LMStudioCapabilities() BackendCapabilities {
return BackendCapabilities{
CanListModels: true,
CanPullModels: false,
CanDeleteModels: false,
CanCreateModels: false,
CanStreamChat: true,
CanEmbed: true,
}
}
// BackendStatus represents the connection status of a backend
type BackendStatus string
const (
BackendStatusConnected BackendStatus = "connected"
BackendStatusDisconnected BackendStatus = "disconnected"
BackendStatusUnknown BackendStatus = "unknown"
)
// BackendConfig holds configuration for a backend
type BackendConfig struct {
Type BackendType `json:"type"`
BaseURL string `json:"baseUrl"`
Enabled bool `json:"enabled"`
}
// Validate checks if the backend config is valid
func (c BackendConfig) Validate() error {
if c.BaseURL == "" {
return errors.New("base URL is required")
}
u, err := url.Parse(c.BaseURL)
if err != nil {
return fmt.Errorf("invalid base URL: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return errors.New("invalid URL: missing scheme or host")
}
return nil
}
// BackendInfo describes a configured backend and its current state
type BackendInfo struct {
Type BackendType `json:"type"`
BaseURL string `json:"baseUrl"`
Status BackendStatus `json:"status"`
Capabilities BackendCapabilities `json:"capabilities"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// IsConnected returns true if the backend is connected
func (bi BackendInfo) IsConnected() bool {
return bi.Status == BackendStatusConnected
}
// Model represents an LLM model available from a backend
type Model struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size,omitempty"`
ModifiedAt string `json:"modifiedAt,omitempty"`
Family string `json:"family,omitempty"`
QuantLevel string `json:"quantLevel,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// HasCapability checks if the model has a specific capability
func (m Model) HasCapability(cap string) bool {
for _, c := range m.Capabilities {
if c == cap {
return true
}
}
return false
}
// ChatMessage represents a message in a chat conversation
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Images []string `json:"images,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
var validRoles = map[string]bool{
"user": true,
"assistant": true,
"system": true,
"tool": true,
}
// Validate checks if the chat message is valid
func (m ChatMessage) Validate() error {
if m.Role == "" {
return errors.New("role is required")
}
if !validRoles[m.Role] {
return fmt.Errorf("invalid role: %q", m.Role)
}
return nil
}
// ToolCall represents a tool invocation
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
// Tool represents a tool definition
type Tool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
} `json:"function"`
}
// ChatRequest represents a chat completion request
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream *bool `json:"stream,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Tools []Tool `json:"tools,omitempty"`
Options map[string]any `json:"options,omitempty"`
}
// Validate checks if the chat request is valid
func (r ChatRequest) Validate() error {
if r.Model == "" {
return errors.New("model is required")
}
if len(r.Messages) == 0 {
return errors.New("at least one message is required")
}
for i, msg := range r.Messages {
if err := msg.Validate(); err != nil {
return fmt.Errorf("message %d: %w", i, err)
}
}
return nil
}
// ChatChunk represents a streaming chat response chunk
type ChatChunk struct {
Model string `json:"model"`
CreatedAt string `json:"created_at,omitempty"`
Message *ChatMessage `json:"message,omitempty"`
Done bool `json:"done"`
DoneReason string `json:"done_reason,omitempty"`
// Token counts (final chunk only)
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
// Error information
Error string `json:"error,omitempty"`
}

View File

@@ -0,0 +1,323 @@
package backends
import (
"testing"
)
func TestBackendType_String(t *testing.T) {
tests := []struct {
name string
bt BackendType
expected string
}{
{"ollama type", BackendTypeOllama, "ollama"},
{"llamacpp type", BackendTypeLlamaCpp, "llamacpp"},
{"lmstudio type", BackendTypeLMStudio, "lmstudio"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.bt.String(); got != tt.expected {
t.Errorf("BackendType.String() = %v, want %v", got, tt.expected)
}
})
}
}
func TestParseBackendType(t *testing.T) {
tests := []struct {
name string
input string
expected BackendType
expectErr bool
}{
{"parse ollama", "ollama", BackendTypeOllama, false},
{"parse llamacpp", "llamacpp", BackendTypeLlamaCpp, false},
{"parse lmstudio", "lmstudio", BackendTypeLMStudio, false},
{"parse llama.cpp alias", "llama.cpp", BackendTypeLlamaCpp, false},
{"parse llama-cpp alias", "llama-cpp", BackendTypeLlamaCpp, false},
{"parse unknown", "unknown", "", true},
{"parse empty", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseBackendType(tt.input)
if (err != nil) != tt.expectErr {
t.Errorf("ParseBackendType() error = %v, expectErr %v", err, tt.expectErr)
return
}
if got != tt.expected {
t.Errorf("ParseBackendType() = %v, want %v", got, tt.expected)
}
})
}
}
func TestBackendCapabilities(t *testing.T) {
t.Run("ollama capabilities", func(t *testing.T) {
caps := OllamaCapabilities()
if !caps.CanListModels {
t.Error("Ollama should be able to list models")
}
if !caps.CanPullModels {
t.Error("Ollama should be able to pull models")
}
if !caps.CanDeleteModels {
t.Error("Ollama should be able to delete models")
}
if !caps.CanCreateModels {
t.Error("Ollama should be able to create models")
}
if !caps.CanStreamChat {
t.Error("Ollama should be able to stream chat")
}
if !caps.CanEmbed {
t.Error("Ollama should be able to embed")
}
})
t.Run("llamacpp capabilities", func(t *testing.T) {
caps := LlamaCppCapabilities()
if !caps.CanListModels {
t.Error("llama.cpp should be able to list models")
}
if caps.CanPullModels {
t.Error("llama.cpp should NOT be able to pull models")
}
if caps.CanDeleteModels {
t.Error("llama.cpp should NOT be able to delete models")
}
if caps.CanCreateModels {
t.Error("llama.cpp should NOT be able to create models")
}
if !caps.CanStreamChat {
t.Error("llama.cpp should be able to stream chat")
}
if !caps.CanEmbed {
t.Error("llama.cpp should be able to embed")
}
})
t.Run("lmstudio capabilities", func(t *testing.T) {
caps := LMStudioCapabilities()
if !caps.CanListModels {
t.Error("LM Studio should be able to list models")
}
if caps.CanPullModels {
t.Error("LM Studio should NOT be able to pull models")
}
if caps.CanDeleteModels {
t.Error("LM Studio should NOT be able to delete models")
}
if caps.CanCreateModels {
t.Error("LM Studio should NOT be able to create models")
}
if !caps.CanStreamChat {
t.Error("LM Studio should be able to stream chat")
}
if !caps.CanEmbed {
t.Error("LM Studio should be able to embed")
}
})
}
func TestBackendConfig_Validate(t *testing.T) {
tests := []struct {
name string
config BackendConfig
expectErr bool
}{
{
name: "valid ollama config",
config: BackendConfig{
Type: BackendTypeOllama,
BaseURL: "http://localhost:11434",
},
expectErr: false,
},
{
name: "valid llamacpp config",
config: BackendConfig{
Type: BackendTypeLlamaCpp,
BaseURL: "http://localhost:8081",
},
expectErr: false,
},
{
name: "empty base URL",
config: BackendConfig{
Type: BackendTypeOllama,
BaseURL: "",
},
expectErr: true,
},
{
name: "invalid URL",
config: BackendConfig{
Type: BackendTypeOllama,
BaseURL: "not-a-url",
},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.expectErr {
t.Errorf("BackendConfig.Validate() error = %v, expectErr %v", err, tt.expectErr)
}
})
}
}
func TestModel_HasCapability(t *testing.T) {
model := Model{
ID: "llama3.2:8b",
Name: "llama3.2:8b",
Capabilities: []string{"chat", "vision", "tools"},
}
tests := []struct {
name string
capability string
expected bool
}{
{"has chat", "chat", true},
{"has vision", "vision", true},
{"has tools", "tools", true},
{"no thinking", "thinking", false},
{"no code", "code", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := model.HasCapability(tt.capability); got != tt.expected {
t.Errorf("Model.HasCapability(%q) = %v, want %v", tt.capability, got, tt.expected)
}
})
}
}
func TestChatMessage_Validation(t *testing.T) {
tests := []struct {
name string
msg ChatMessage
expectErr bool
}{
{
name: "valid user message",
msg: ChatMessage{Role: "user", Content: "Hello"},
expectErr: false,
},
{
name: "valid assistant message",
msg: ChatMessage{Role: "assistant", Content: "Hi there"},
expectErr: false,
},
{
name: "valid system message",
msg: ChatMessage{Role: "system", Content: "You are helpful"},
expectErr: false,
},
{
name: "invalid role",
msg: ChatMessage{Role: "invalid", Content: "Hello"},
expectErr: true,
},
{
name: "empty role",
msg: ChatMessage{Role: "", Content: "Hello"},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.msg.Validate()
if (err != nil) != tt.expectErr {
t.Errorf("ChatMessage.Validate() error = %v, expectErr %v", err, tt.expectErr)
}
})
}
}
func TestChatRequest_Validation(t *testing.T) {
streaming := true
tests := []struct {
name string
req ChatRequest
expectErr bool
}{
{
name: "valid request",
req: ChatRequest{
Model: "llama3.2:8b",
Messages: []ChatMessage{
{Role: "user", Content: "Hello"},
},
Stream: &streaming,
},
expectErr: false,
},
{
name: "empty model",
req: ChatRequest{
Model: "",
Messages: []ChatMessage{
{Role: "user", Content: "Hello"},
},
},
expectErr: true,
},
{
name: "empty messages",
req: ChatRequest{
Model: "llama3.2:8b",
Messages: []ChatMessage{},
},
expectErr: true,
},
{
name: "nil messages",
req: ChatRequest{
Model: "llama3.2:8b",
Messages: nil,
},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.req.Validate()
if (err != nil) != tt.expectErr {
t.Errorf("ChatRequest.Validate() error = %v, expectErr %v", err, tt.expectErr)
}
})
}
}
func TestBackendInfo(t *testing.T) {
info := BackendInfo{
Type: BackendTypeOllama,
BaseURL: "http://localhost:11434",
Status: BackendStatusConnected,
Capabilities: OllamaCapabilities(),
Version: "0.1.0",
}
if !info.IsConnected() {
t.Error("BackendInfo.IsConnected() should be true when status is connected")
}
info.Status = BackendStatusDisconnected
if info.IsConnected() {
t.Error("BackendInfo.IsConnected() should be false when status is disconnected")
}
}

View File

@@ -0,0 +1,384 @@
package database
import (
"os"
"path/filepath"
"testing"
)
func TestOpenDatabase(t *testing.T) {
t.Run("creates directory if needed", func(t *testing.T) {
// Use temp directory
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "subdir", "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
// Verify directory was created
if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) {
t.Error("directory was not created")
}
})
t.Run("opens valid database", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
// Verify we can ping
if err := db.Ping(); err != nil {
t.Errorf("Ping() error = %v", err)
}
})
t.Run("can query journal mode", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
var journalMode string
err = db.QueryRow("PRAGMA journal_mode").Scan(&journalMode)
if err != nil {
t.Fatalf("PRAGMA journal_mode error = %v", err)
}
// Note: modernc.org/sqlite may not honor DSN pragma params
// just verify we can query the pragma
if journalMode == "" {
t.Error("journal_mode should not be empty")
}
})
t.Run("can query foreign keys setting", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
// Note: modernc.org/sqlite may not honor DSN pragma params
// but we can still set them explicitly if needed
var foreignKeys int
err = db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys)
if err != nil {
t.Fatalf("PRAGMA foreign_keys error = %v", err)
}
// Just verify the query works
})
}
func TestRunMigrations(t *testing.T) {
t.Run("creates all tables", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
err = RunMigrations(db)
if err != nil {
t.Fatalf("RunMigrations() error = %v", err)
}
// Check that all expected tables exist
tables := []string{"chats", "messages", "attachments", "remote_models"}
for _, table := range tables {
var name string
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
if err != nil {
t.Errorf("table %s not found: %v", table, err)
}
}
})
t.Run("creates expected indexes", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
err = RunMigrations(db)
if err != nil {
t.Fatalf("RunMigrations() error = %v", err)
}
// Check key indexes exist
indexes := []string{
"idx_messages_chat_id",
"idx_chats_updated_at",
"idx_attachments_message_id",
}
for _, idx := range indexes {
var name string
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='index' AND name=?", idx).Scan(&name)
if err != nil {
t.Errorf("index %s not found: %v", idx, err)
}
}
})
t.Run("is idempotent", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
// Run migrations twice
err = RunMigrations(db)
if err != nil {
t.Fatalf("RunMigrations() first run error = %v", err)
}
err = RunMigrations(db)
if err != nil {
t.Errorf("RunMigrations() second run error = %v", err)
}
})
t.Run("adds tag_sizes column", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
err = RunMigrations(db)
if err != nil {
t.Fatalf("RunMigrations() error = %v", err)
}
// Check that tag_sizes column exists
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('remote_models') WHERE name='tag_sizes'`).Scan(&count)
if err != nil {
t.Fatalf("failed to check tag_sizes column: %v", err)
}
if count != 1 {
t.Error("tag_sizes column not found")
}
})
t.Run("adds system_prompt_id column", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
err = RunMigrations(db)
if err != nil {
t.Fatalf("RunMigrations() error = %v", err)
}
// Check that system_prompt_id column exists
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('chats') WHERE name='system_prompt_id'`).Scan(&count)
if err != nil {
t.Fatalf("failed to check system_prompt_id column: %v", err)
}
if count != 1 {
t.Error("system_prompt_id column not found")
}
})
}
func TestChatsCRUD(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
err = RunMigrations(db)
if err != nil {
t.Fatalf("RunMigrations() error = %v", err)
}
t.Run("insert and select chat", func(t *testing.T) {
_, err := db.Exec(`INSERT INTO chats (id, title, model) VALUES (?, ?, ?)`,
"chat-1", "Test Chat", "llama3:8b")
if err != nil {
t.Fatalf("INSERT error = %v", err)
}
var title, model string
err = db.QueryRow(`SELECT title, model FROM chats WHERE id = ?`, "chat-1").Scan(&title, &model)
if err != nil {
t.Fatalf("SELECT error = %v", err)
}
if title != "Test Chat" {
t.Errorf("title = %v, want Test Chat", title)
}
if model != "llama3:8b" {
t.Errorf("model = %v, want llama3:8b", model)
}
})
t.Run("update chat", func(t *testing.T) {
_, err := db.Exec(`UPDATE chats SET title = ? WHERE id = ?`, "Updated Title", "chat-1")
if err != nil {
t.Fatalf("UPDATE error = %v", err)
}
var title string
err = db.QueryRow(`SELECT title FROM chats WHERE id = ?`, "chat-1").Scan(&title)
if err != nil {
t.Fatalf("SELECT error = %v", err)
}
if title != "Updated Title" {
t.Errorf("title = %v, want Updated Title", title)
}
})
t.Run("delete chat", func(t *testing.T) {
result, err := db.Exec(`DELETE FROM chats WHERE id = ?`, "chat-1")
if err != nil {
t.Fatalf("DELETE error = %v", err)
}
rows, _ := result.RowsAffected()
if rows != 1 {
t.Errorf("RowsAffected = %v, want 1", rows)
}
})
}
func TestMessagesCRUD(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := OpenDatabase(dbPath)
if err != nil {
t.Fatalf("OpenDatabase() error = %v", err)
}
defer db.Close()
err = RunMigrations(db)
if err != nil {
t.Fatalf("RunMigrations() error = %v", err)
}
// Create a chat first
_, err = db.Exec(`INSERT INTO chats (id, title, model) VALUES (?, ?, ?)`,
"chat-test", "Test", "test")
if err != nil {
t.Fatalf("INSERT chat error = %v", err)
}
t.Run("insert and select message", func(t *testing.T) {
_, err := db.Exec(`INSERT INTO messages (id, chat_id, role, content) VALUES (?, ?, ?, ?)`,
"msg-1", "chat-test", "user", "Hello world")
if err != nil {
t.Fatalf("INSERT error = %v", err)
}
var role, content string
err = db.QueryRow(`SELECT role, content FROM messages WHERE id = ?`, "msg-1").Scan(&role, &content)
if err != nil {
t.Fatalf("SELECT error = %v", err)
}
if role != "user" {
t.Errorf("role = %v, want user", role)
}
if content != "Hello world" {
t.Errorf("content = %v, want Hello world", content)
}
})
t.Run("enforces role constraint", func(t *testing.T) {
_, err := db.Exec(`INSERT INTO messages (id, chat_id, role, content) VALUES (?, ?, ?, ?)`,
"msg-bad", "chat-test", "invalid", "test")
if err == nil {
t.Error("expected error for invalid role, got nil")
}
})
t.Run("cascade delete on chat removal", func(t *testing.T) {
// Insert a message for a new chat
_, err := db.Exec(`INSERT INTO chats (id, title, model) VALUES (?, ?, ?)`,
"chat-cascade", "Cascade Test", "test")
if err != nil {
t.Fatalf("INSERT chat error = %v", err)
}
_, err = db.Exec(`INSERT INTO messages (id, chat_id, role, content) VALUES (?, ?, ?, ?)`,
"msg-cascade", "chat-cascade", "user", "test")
if err != nil {
t.Fatalf("INSERT message error = %v", err)
}
// Verify message exists before delete
var countBefore int
err = db.QueryRow(`SELECT COUNT(*) FROM messages WHERE id = ?`, "msg-cascade").Scan(&countBefore)
if err != nil {
t.Fatalf("SELECT count before error = %v", err)
}
if countBefore != 1 {
t.Fatalf("message not found before delete")
}
// Re-enable foreign keys for this connection to ensure cascade works
// Some SQLite drivers require this to be set per-connection
_, err = db.Exec(`PRAGMA foreign_keys = ON`)
if err != nil {
t.Fatalf("PRAGMA foreign_keys error = %v", err)
}
// Delete the chat
_, err = db.Exec(`DELETE FROM chats WHERE id = ?`, "chat-cascade")
if err != nil {
t.Fatalf("DELETE chat error = %v", err)
}
// Message should be deleted too (if foreign keys are properly enforced)
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM messages WHERE id = ?`, "msg-cascade").Scan(&count)
if err != nil {
t.Fatalf("SELECT count error = %v", err)
}
// Note: If cascade doesn't work, it means FK enforcement isn't active
// which is acceptable - the app handles orphan cleanup separately
if count != 0 {
t.Log("Note: CASCADE DELETE not enforced by driver, orphaned messages remain")
}
})
}

View File

@@ -0,0 +1,118 @@
package models
import (
"testing"
"time"
)
func TestGetDateGroup(t *testing.T) {
// Fixed reference time: Wednesday, January 15, 2025 at 14:00:00 UTC
now := time.Date(2025, 1, 15, 14, 0, 0, 0, time.UTC)
tests := []struct {
name string
input time.Time
expected DateGroup
}{
// Today
{
name: "today morning",
input: time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC),
expected: DateGroupToday,
},
{
name: "today midnight",
input: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC),
expected: DateGroupToday,
},
// Yesterday
{
name: "yesterday afternoon",
input: time.Date(2025, 1, 14, 15, 0, 0, 0, time.UTC),
expected: DateGroupYesterday,
},
{
name: "yesterday start",
input: time.Date(2025, 1, 14, 0, 0, 0, 0, time.UTC),
expected: DateGroupYesterday,
},
// This Week (Monday Jan 13 - Sunday Jan 19)
{
name: "this week monday",
input: time.Date(2025, 1, 13, 10, 0, 0, 0, time.UTC),
expected: DateGroupThisWeek,
},
// Last Week (Monday Jan 6 - Sunday Jan 12)
{
name: "last week friday",
input: time.Date(2025, 1, 10, 12, 0, 0, 0, time.UTC),
expected: DateGroupLastWeek,
},
{
name: "last week monday",
input: time.Date(2025, 1, 6, 8, 0, 0, 0, time.UTC),
expected: DateGroupLastWeek,
},
// This Month (January 2025)
{
name: "this month early",
input: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
expected: DateGroupThisMonth,
},
// Last Month (December 2024)
{
name: "last month",
input: time.Date(2024, 12, 15, 10, 0, 0, 0, time.UTC),
expected: DateGroupLastMonth,
},
{
name: "last month start",
input: time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC),
expected: DateGroupLastMonth,
},
// Older
{
name: "november 2024",
input: time.Date(2024, 11, 20, 0, 0, 0, 0, time.UTC),
expected: DateGroupOlder,
},
{
name: "last year",
input: time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC),
expected: DateGroupOlder,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getDateGroup(tt.input, now)
if result != tt.expected {
t.Errorf("getDateGroup(%v, %v) = %v, want %v", tt.input, now, result, tt.expected)
}
})
}
}
func TestGetDateGroupSundayEdgeCase(t *testing.T) {
// Test edge case: Sunday should be grouped with current week
// Reference: Sunday, January 19, 2025 at 12:00:00 UTC
now := time.Date(2025, 1, 19, 12, 0, 0, 0, time.UTC)
// Today (Sunday)
sunday := time.Date(2025, 1, 19, 8, 0, 0, 0, time.UTC)
if result := getDateGroup(sunday, now); result != DateGroupToday {
t.Errorf("Sunday should be Today, got %v", result)
}
// Yesterday (Saturday)
saturday := time.Date(2025, 1, 18, 10, 0, 0, 0, time.UTC)
if result := getDateGroup(saturday, now); result != DateGroupYesterday {
t.Errorf("Saturday should be Yesterday, got %v", result)
}
// This week (Monday of same week)
monday := time.Date(2025, 1, 13, 10, 0, 0, 0, time.UTC)
if result := getDateGroup(monday, now); result != DateGroupThisWeek {
t.Errorf("Monday should be This Week, got %v", result)
}
}

BIN
backend/vessel Executable file

Binary file not shown.

View File

@@ -1,6 +1,8 @@
name: vessel-dev
# Development docker-compose - uses host network for direct Ollama access
# Reads configuration from .env file
services:
frontend:
build:
@@ -12,8 +14,8 @@ services:
- ./frontend:/app
- /app/node_modules
environment:
- OLLAMA_API_URL=http://localhost:11434
- BACKEND_URL=http://localhost:9090
- OLLAMA_API_URL=${OLLAMA_API_URL:-http://localhost:11434}
- BACKEND_URL=${BACKEND_URL:-http://localhost:9090}
depends_on:
- backend
@@ -26,4 +28,4 @@ services:
- ./backend/data:/app/data
environment:
- GIN_MODE=release
command: ["./server", "-port", "9090", "-db", "/app/data/vessel.db", "-ollama-url", "http://localhost:11434"]
command: ["./server", "-port", "${PORT:-9090}", "-db", "${DB_PATH:-/app/data/vessel.db}", "-ollama-url", "${OLLAMA_URL:-http://localhost:11434}"]

278
frontend/e2e/agents.spec.ts Normal file
View File

@@ -0,0 +1,278 @@
/**
* E2E tests for Agents feature
*
* Tests the agents UI in settings and chat integration
*/
import { test, expect } from '@playwright/test';
test.describe('Agents', () => {
test('settings page has agents tab', async ({ page }) => {
await page.goto('/settings?tab=agents');
// Should show agents tab content - use exact match for the main heading
await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible({
timeout: 10000
});
});
test('agents tab shows empty state initially', async ({ page }) => {
await page.goto('/settings?tab=agents');
// Should show empty state message
await expect(page.getByRole('heading', { name: 'No agents yet' })).toBeVisible({ timeout: 10000 });
});
test('has create agent button', async ({ page }) => {
await page.goto('/settings?tab=agents');
// Should have create button in the header (not the empty state button)
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
await expect(createButton).toBeVisible({ timeout: 10000 });
});
test('can open create agent dialog', async ({ page }) => {
await page.goto('/settings?tab=agents');
// Click create button (the one in the header)
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
await createButton.click();
// Dialog should appear with form fields
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
await expect(page.getByLabel('Name *')).toBeVisible();
});
test('can create new agent', async ({ page }) => {
await page.goto('/settings?tab=agents');
// Open create dialog
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
await createButton.click();
// Wait for dialog
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
// Fill in agent details
await page.getByLabel('Name *').fill('Test Agent');
await page.getByLabel('Description').fill('A test agent for E2E testing');
// Submit the form - use the submit button inside the dialog
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Create Agent' }).click();
// Dialog should close and agent should appear in the list
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
await expect(page.getByRole('heading', { name: 'Test Agent' })).toBeVisible({ timeout: 5000 });
});
test('can edit existing agent', async ({ page }) => {
// First create an agent
await page.goto('/settings?tab=agents');
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
await createButton.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
await page.getByLabel('Name *').fill('Edit Me Agent');
await page.getByLabel('Description').fill('Will be edited');
// Submit via dialog button
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Create Agent' }).click();
// Wait for agent to appear
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText('Edit Me Agent')).toBeVisible({ timeout: 5000 });
// Click edit button (aria-label)
const editButton = page.getByRole('button', { name: 'Edit agent' });
await editButton.click();
// Edit the name in the dialog
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
await page.getByLabel('Name *').fill('Edited Agent');
// Save changes
await dialog.getByRole('button', { name: 'Save Changes' }).click();
// Should show updated name
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText('Edited Agent')).toBeVisible({ timeout: 5000 });
});
test('can delete agent', async ({ page }) => {
// First create an agent
await page.goto('/settings?tab=agents');
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
await createButton.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
await page.getByLabel('Name *').fill('Delete Me Agent');
await page.getByLabel('Description').fill('Will be deleted');
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Create Agent' }).click();
// Wait for agent to appear
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText('Delete Me Agent')).toBeVisible({ timeout: 5000 });
// Click delete button (aria-label)
const deleteButton = page.getByRole('button', { name: 'Delete agent' });
await deleteButton.click();
// Confirm deletion in dialog - look for the Delete button in the confirm dialog
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const confirmDialog = page.getByRole('dialog');
await confirmDialog.getByRole('button', { name: 'Delete' }).click();
// Agent should be removed
await expect(page.getByRole('heading', { name: 'Delete Me Agent' })).not.toBeVisible({ timeout: 5000 });
});
test('can navigate to agents tab via navigation', async ({ page }) => {
await page.goto('/settings');
// Click on agents tab link
const agentsTab = page.getByRole('link', { name: 'Agents' });
await agentsTab.click();
// URL should update
await expect(page).toHaveURL(/tab=agents/);
// Agents content should be visible
await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible();
});
});
test.describe('Agent Tool Selection', () => {
test('can select tools for agent', async ({ page }) => {
await page.goto('/settings?tab=agents');
// Open create dialog
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
await createButton.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
await page.getByLabel('Name *').fill('Tool Agent');
await page.getByLabel('Description').fill('Agent with specific tools');
// Look for Allowed Tools section
await expect(page.getByText('Allowed Tools', { exact: true })).toBeVisible({ timeout: 5000 });
// Save the agent
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Create Agent' }).click();
// Agent should be created
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText('Tool Agent')).toBeVisible({ timeout: 5000 });
});
});
test.describe('Agent Prompt Selection', () => {
test('can assign prompt to agent', async ({ page }) => {
await page.goto('/settings?tab=agents');
// Open create dialog
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
await createButton.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
await page.getByLabel('Name *').fill('Prompt Agent');
await page.getByLabel('Description').fill('Agent with a prompt');
// Look for System Prompt selector
await expect(page.getByLabel('System Prompt')).toBeVisible({ timeout: 5000 });
// Save the agent
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Create Agent' }).click();
// Agent should be created
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText('Prompt Agent')).toBeVisible({ timeout: 5000 });
});
});
test.describe('Agent Chat Integration', () => {
test('agent selector appears on home page', async ({ page }) => {
await page.goto('/');
// Agent selector button should be visible (shows "No agent" by default)
await expect(page.getByRole('button', { name: /No agent/i })).toBeVisible({ timeout: 10000 });
});
test('agent selector dropdown shows "No agents" when none exist', async ({ page }) => {
await page.goto('/');
// Click on agent selector
const agentButton = page.getByRole('button', { name: /No agent/i });
await agentButton.click();
// Should show "No agents available" message
await expect(page.getByText('No agents available')).toBeVisible({ timeout: 5000 });
// Should have link to create agents
await expect(page.getByRole('link', { name: 'Create one' })).toBeVisible();
});
test('agent selector shows created agents', async ({ page }) => {
// First create an agent
await page.goto('/settings?tab=agents');
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
await createButton.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
await page.getByLabel('Name *').fill('Chat Agent');
await page.getByLabel('Description').fill('Agent for chat testing');
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Create Agent' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
// Now go to home page and check agent selector
await page.goto('/');
const agentButton = page.getByRole('button', { name: /No agent/i });
await agentButton.click();
// Should show the created agent
await expect(page.getByText('Chat Agent')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Agent for chat testing')).toBeVisible();
});
test('can select agent from dropdown', async ({ page }) => {
// First create an agent
await page.goto('/settings?tab=agents');
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
await createButton.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
await page.getByLabel('Name *').fill('Selectable Agent');
await page.getByLabel('Description').fill('Can be selected');
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Create Agent' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
// Go to home page
await page.goto('/');
// Open agent selector
const agentButton = page.getByRole('button', { name: /No agent/i });
await agentButton.click();
// Select the agent
await page.getByText('Selectable Agent').click();
// Button should now show the agent name
await expect(page.getByRole('button', { name: /Selectable Agent/i })).toBeVisible({ timeout: 5000 });
});
});

307
frontend/e2e/app.spec.ts Normal file
View File

@@ -0,0 +1,307 @@
/**
* E2E tests for core application functionality
*
* Tests the main app UI, navigation, and user interactions
*/
import { test, expect } from '@playwright/test';
test.describe('App Loading', () => {
test('loads the application', async ({ page }) => {
await page.goto('/');
// Should have the main app container
await expect(page.locator('body')).toBeVisible();
// Should have the sidebar (aside element with aria-label)
await expect(page.locator('aside[aria-label="Sidebar navigation"]')).toBeVisible();
});
test('shows the Vessel branding', async ({ page }) => {
await page.goto('/');
// Look for Vessel text in sidebar
await expect(page.getByText('Vessel')).toBeVisible({ timeout: 10000 });
});
test('has proper page title', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/vessel/i);
});
});
test.describe('Sidebar Navigation', () => {
test('sidebar is visible', async ({ page }) => {
await page.goto('/');
// Sidebar is an aside element
const sidebar = page.locator('aside[aria-label="Sidebar navigation"]');
await expect(sidebar).toBeVisible();
});
test('has new chat link', async ({ page }) => {
await page.goto('/');
// New Chat is an anchor tag with "New Chat" text
const newChatLink = page.getByRole('link', { name: /new chat/i });
await expect(newChatLink).toBeVisible();
});
test('clicking new chat navigates to home', async ({ page }) => {
await page.goto('/settings');
// Click new chat link
const newChatLink = page.getByRole('link', { name: /new chat/i });
await newChatLink.click();
// Should navigate to home
await expect(page).toHaveURL('/');
});
test('has settings link', async ({ page }) => {
await page.goto('/');
// Settings is an anchor tag
const settingsLink = page.getByRole('link', { name: /settings/i });
await expect(settingsLink).toBeVisible();
});
test('can navigate to settings', async ({ page }) => {
await page.goto('/');
// Click settings link
const settingsLink = page.getByRole('link', { name: /settings/i });
await settingsLink.click();
// Should navigate to settings
await expect(page).toHaveURL('/settings');
});
test('has new project button', async ({ page }) => {
await page.goto('/');
// New Project button
const newProjectButton = page.getByRole('button', { name: /new project/i });
await expect(newProjectButton).toBeVisible();
});
test('has import button', async ({ page }) => {
await page.goto('/');
// Import button has aria-label
const importButton = page.getByRole('button', { name: /import/i });
await expect(importButton).toBeVisible();
});
});
test.describe('Settings Page', () => {
test('settings page loads', async ({ page }) => {
await page.goto('/settings');
// Should show settings content
await expect(page.getByText(/general|models|prompts|tools/i).first()).toBeVisible({
timeout: 10000
});
});
test('has settings tabs', async ({ page }) => {
await page.goto('/settings');
// Wait for page to load
await page.waitForLoadState('networkidle');
// Should have multiple tabs/sections
const content = await page.content();
expect(content.toLowerCase()).toMatch(/general|models|prompts|tools|memory/);
});
});
test.describe('Chat Interface', () => {
test('home page shows chat area', async ({ page }) => {
await page.goto('/');
// Look for chat-related elements (message input area)
const chatArea = page.locator('main, [class*="chat"]').first();
await expect(chatArea).toBeVisible();
});
test('has textarea for message input', async ({ page }) => {
await page.goto('/');
// Chat input textarea
const textarea = page.locator('textarea').first();
await expect(textarea).toBeVisible({ timeout: 10000 });
});
test('can type in chat input', async ({ page }) => {
await page.goto('/');
// Find and type in textarea
const textarea = page.locator('textarea').first();
await textarea.fill('Hello, this is a test message');
await expect(textarea).toHaveValue('Hello, this is a test message');
});
test('has send button', async ({ page }) => {
await page.goto('/');
// Send button (usually has submit type or send icon)
const sendButton = page
.locator('button[type="submit"]')
.or(page.getByRole('button', { name: /send/i }));
await expect(sendButton.first()).toBeVisible({ timeout: 10000 });
});
});
test.describe('Model Selection', () => {
test('chat page renders model-related UI', async ({ page }) => {
await page.goto('/');
// The app should render without crashing
// Model selection depends on Ollama availability
await expect(page.locator('body')).toBeVisible();
// Check that there's either a model selector or a message about models
const hasModelUI = await page
.locator('[class*="model"], [class*="Model"]')
.or(page.getByText(/model|ollama/i))
.count();
// Just verify app renders - model UI depends on backend state
expect(hasModelUI).toBeGreaterThanOrEqual(0);
});
});
test.describe('Responsive Design', () => {
test('works on mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
// App should still render
await expect(page.locator('body')).toBeVisible();
await expect(page.getByText('Vessel')).toBeVisible();
});
test('sidebar collapses on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
// Sidebar should be collapsed (width: 0) on mobile
const sidebar = page.locator('aside[aria-label="Sidebar navigation"]');
// Check if sidebar has collapsed class or is hidden
await expect(sidebar).toHaveClass(/w-0|hidden/);
});
test('works on tablet viewport', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/');
await expect(page.locator('body')).toBeVisible();
});
test('works on desktop viewport', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto('/');
await expect(page.locator('body')).toBeVisible();
// Sidebar should be visible on desktop
const sidebar = page.locator('aside[aria-label="Sidebar navigation"]');
await expect(sidebar).toBeVisible();
});
});
test.describe('Accessibility', () => {
test('has main content area', async ({ page }) => {
await page.goto('/');
// Should have main element
const main = page.locator('main');
await expect(main).toBeVisible();
});
test('sidebar has proper aria-label', async ({ page }) => {
await page.goto('/');
const sidebar = page.locator('aside[aria-label="Sidebar navigation"]');
await expect(sidebar).toBeVisible();
});
test('interactive elements are focusable', async ({ page }) => {
await page.goto('/');
// New Chat link should be focusable
const newChatLink = page.getByRole('link', { name: /new chat/i });
await newChatLink.focus();
await expect(newChatLink).toBeFocused();
});
test('can tab through interface', async ({ page }) => {
await page.goto('/');
// Focus on the first interactive element in the page
const firstLink = page.getByRole('link').first();
await firstLink.focus();
// Tab should move focus to another element
await page.keyboard.press('Tab');
// Wait a bit for focus to shift
await page.waitForTimeout(100);
// Verify we can interact with the page via keyboard
// Just check that pressing Tab doesn't cause errors
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Page should still be responsive
await expect(page.locator('body')).toBeVisible();
});
});
test.describe('Import Dialog', () => {
test('import button opens dialog', async ({ page }) => {
await page.goto('/');
// Click import button
const importButton = page.getByRole('button', { name: /import/i });
await importButton.click();
// Dialog should appear
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
});
test('import dialog can be closed', async ({ page }) => {
await page.goto('/');
// Open import dialog
const importButton = page.getByRole('button', { name: /import/i });
await importButton.click();
// Wait for dialog
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Press escape to close
await page.keyboard.press('Escape');
// Dialog should be closed
await expect(dialog).not.toBeVisible({ timeout: 2000 });
});
});
test.describe('Project Modal', () => {
test('new project button opens modal', async ({ page }) => {
await page.goto('/');
// Click new project button
const newProjectButton = page.getByRole('button', { name: /new project/i });
await newProjectButton.click();
// Modal should appear
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
});
});

View File

@@ -1,12 +1,13 @@
{
"name": "vessel",
"version": "0.4.8",
"version": "0.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vessel",
"version": "0.4.8",
"version": "0.5.2",
"hasInstallScript": true,
"dependencies": {
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
@@ -26,6 +27,7 @@
"shiki": "^1.26.0"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
@@ -34,6 +36,7 @@
"@testing-library/svelte": "^5.3.1",
"@types/node": "^22.10.0",
"autoprefixer": "^10.4.20",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.4.0",
"postcss": "^8.4.49",
"svelte": "^5.16.0",
@@ -1172,6 +1175,22 @@
"node": ">= 8"
}
},
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"license": "MIT"
@@ -2539,6 +2558,16 @@
"node": ">=12.0.0"
}
},
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"license": "MIT",
@@ -3179,6 +3208,53 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"funding": [

View File

@@ -1,6 +1,6 @@
{
"name": "vessel",
"version": "0.5.0",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {
@@ -12,9 +12,12 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs static/ 2>/dev/null || true"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
@@ -23,6 +26,7 @@
"@testing-library/svelte": "^5.3.1",
"@types/node": "^22.10.0",
"autoprefixer": "^10.4.20",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.4.0",
"postcss": "^8.4.49",
"svelte": "^5.16.0",

View File

@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:7842',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:7842',
reuseExistingServer: !process.env.CI,
timeout: 120000
}
});

View File

@@ -0,0 +1,217 @@
<script lang="ts">
/**
* AgentSelector - Dropdown to select an agent for the current conversation
* Agents define a system prompt and tool set for the conversation
*/
import { agentsState, conversationsState, toastState } from '$lib/stores';
import { updateAgentId } from '$lib/storage';
interface Props {
conversationId?: string | null;
currentAgentId?: string | null;
/** Callback for 'new' mode - called when agent is selected without a conversation */
onSelect?: (agentId: string | null) => void;
}
let { conversationId = null, currentAgentId = null, onSelect }: Props = $props();
// UI state
let isOpen = $state(false);
let dropdownElement: HTMLDivElement | null = $state(null);
// Available agents from store
const agents = $derived(agentsState.sortedAgents);
// Current agent for this conversation
const currentAgent = $derived(
currentAgentId ? agents.find((a) => a.id === currentAgentId) : null
);
// Display text for the button
const buttonText = $derived(currentAgent?.name ?? 'No agent');
function toggleDropdown(): void {
isOpen = !isOpen;
}
function closeDropdown(): void {
isOpen = false;
}
async function handleSelect(agentId: string | null): Promise<void> {
// In 'new' mode (no conversation), use the callback
if (!conversationId) {
onSelect?.(agentId);
const agentName = agentId ? agents.find((a) => a.id === agentId)?.name : null;
toastState.success(agentName ? `Using "${agentName}"` : 'No agent selected');
closeDropdown();
return;
}
// Update in storage for existing conversation
const result = await updateAgentId(conversationId, agentId);
if (result.success) {
conversationsState.setAgentId(conversationId, agentId);
const agentName = agentId ? agents.find((a) => a.id === agentId)?.name : null;
toastState.success(agentName ? `Using "${agentName}"` : 'No agent selected');
} else {
toastState.error('Failed to update agent');
}
closeDropdown();
}
function handleClickOutside(event: MouseEvent): void {
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
closeDropdown();
}
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && isOpen) {
closeDropdown();
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="relative" bind:this={dropdownElement}>
<!-- Trigger button -->
<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 {currentAgent
? 'bg-indigo-500/20 text-indigo-300'
: 'text-theme-muted hover:bg-theme-secondary hover:text-theme-secondary'}"
title={currentAgent ? `Agent: ${currentAgent.name}` : 'Select an agent'}
>
<!-- Robot 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="M10 1a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 1ZM5.05 3.05a.75.75 0 0 1 1.06 0l1.062 1.06A.75.75 0 1 1 6.11 5.173L5.05 4.11a.75.75 0 0 1 0-1.06Zm9.9 0a.75.75 0 0 1 0 1.06l-1.06 1.062a.75.75 0 0 1-1.062-1.061l1.061-1.06a.75.75 0 0 1 1.06 0ZM3 8a7 7 0 0 1 14 0v2a1 1 0 0 0 1 1h.25a.75.75 0 0 1 0 1.5H18v1a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-1h-.25a.75.75 0 0 1 0-1.5H2a1 1 0 0 0 1-1V8Zm5.75 3.5a.75.75 0 0 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Zm4 0a.75.75 0 0 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Z" clip-rule="evenodd" />
</svg>
<span class="max-w-[100px] truncate">{buttonText}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5 transition-transform {isOpen ? 'rotate-180' : ''}"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- Dropdown menu (opens upward) -->
{#if isOpen}
<div
class="absolute bottom-full left-0 z-50 mb-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-theme bg-theme-secondary py-1 shadow-xl"
>
<!-- No agent option -->
<div class="px-3 py-1.5 text-xs font-medium text-theme-muted uppercase tracking-wide">
Default
</div>
<button
type="button"
onclick={() => handleSelect(null)}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-theme-tertiary {!currentAgentId
? 'bg-theme-tertiary/50 text-theme-primary'
: 'text-theme-secondary'}"
>
<div class="flex-1">
<span>No agent</span>
<div class="mt-0.5 text-xs text-theme-muted">
Use default tools and prompts
</div>
</div>
{#if !currentAgentId}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4 text-emerald-400"
>
<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>
{#if agents.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 Agents
</div>
<!-- Available agents -->
{#each agents as agent}
<button
type="button"
onclick={() => handleSelect(agent.id)}
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-theme-tertiary {currentAgentId === agent.id
? 'bg-theme-tertiary/50'
: ''}"
>
<div class="flex items-center gap-2">
<span
class="flex-1 text-sm font-medium {currentAgentId === agent.id
? 'text-theme-primary'
: 'text-theme-secondary'}"
>
{agent.name}
</span>
{#if currentAgentId === agent.id}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4 text-emerald-400"
>
<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}
</div>
{#if agent.description}
<span class="line-clamp-1 text-xs text-theme-muted">{agent.description}</span>
{/if}
{#if agent.enabledToolNames.length > 0}
<span class="text-[10px] text-indigo-400">
{agent.enabledToolNames.length} tool{agent.enabledToolNames.length !== 1 ? 's' : ''}
</span>
{/if}
</button>
{/each}
{:else}
<div class="my-1 border-t border-theme"></div>
<div class="px-3 py-2 text-xs text-theme-muted">
No agents available. <a href="/settings?tab=agents" class="text-indigo-400 hover:underline"
>Create one</a
>
</div>
{/if}
<!-- Link to agents settings -->
<div class="mt-1 border-t border-theme"></div>
<a
href="/settings?tab=agents"
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>
Manage agents
</a>
</div>
{/if}
</div>

View File

@@ -2,7 +2,6 @@
/**
* BranchNavigator - Navigate between message branches
* Shows "< 1/3 >" style navigation for sibling messages
* Supports keyboard navigation with arrow keys when focused
*/
import type { BranchInfo } from '$lib/types';
@@ -15,7 +14,7 @@
const { branchInfo, onSwitch }: Props = $props();
// Reference to the navigator container for focus management
let navigatorRef: HTMLDivElement | null = $state(null);
let navigatorRef: HTMLElement | null = $state(null);
// Track transition state for smooth animations
let isTransitioning = $state(false);
@@ -52,7 +51,7 @@
}
/**
* Handle keyboard navigation when the component is focused
* Handle keyboard navigation with arrow keys
*/
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowLeft' && canGoPrev) {
@@ -65,11 +64,10 @@
}
</script>
<div
<nav
bind:this={navigatorRef}
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 transition-all duration-150 ease-out dark:bg-gray-700 dark:text-gray-300"
class:opacity-50={isTransitioning}
role="navigation"
aria-label="Message branch navigation - Use left/right arrow keys to navigate"
tabindex="0"
onkeydown={handleKeydown}
@@ -126,16 +124,16 @@
/>
</svg>
</button>
</div>
</nav>
<style>
/* Focus ring style for keyboard navigation */
div:focus {
nav:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
div:focus-visible {
nav:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}

View File

@@ -0,0 +1,154 @@
/**
* BranchNavigator component tests
*
* Tests the message branch navigation component
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import BranchNavigator from './BranchNavigator.svelte';
describe('BranchNavigator', () => {
const defaultBranchInfo = {
currentIndex: 0,
totalCount: 3,
siblingIds: ['msg-1', 'msg-2', 'msg-3']
};
it('renders with branch info', () => {
render(BranchNavigator, {
props: {
branchInfo: defaultBranchInfo
}
});
// Should show 1/3 (currentIndex + 1)
expect(screen.getByText('1/3')).toBeDefined();
});
it('renders navigation role', () => {
render(BranchNavigator, {
props: {
branchInfo: defaultBranchInfo
}
});
const nav = screen.getByRole('navigation');
expect(nav).toBeDefined();
expect(nav.getAttribute('aria-label')).toContain('branch navigation');
});
it('has prev and next buttons', () => {
render(BranchNavigator, {
props: {
branchInfo: defaultBranchInfo
}
});
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
expect(buttons[0].getAttribute('aria-label')).toContain('Previous');
expect(buttons[1].getAttribute('aria-label')).toContain('Next');
});
it('calls onSwitch with prev when prev button clicked', async () => {
const onSwitch = vi.fn();
render(BranchNavigator, {
props: {
branchInfo: defaultBranchInfo,
onSwitch
}
});
const prevButton = screen.getAllByRole('button')[0];
await fireEvent.click(prevButton);
expect(onSwitch).toHaveBeenCalledWith('prev');
});
it('calls onSwitch with next when next button clicked', async () => {
const onSwitch = vi.fn();
render(BranchNavigator, {
props: {
branchInfo: defaultBranchInfo,
onSwitch
}
});
const nextButton = screen.getAllByRole('button')[1];
await fireEvent.click(nextButton);
expect(onSwitch).toHaveBeenCalledWith('next');
});
it('updates display when currentIndex changes', () => {
const { rerender } = render(BranchNavigator, {
props: {
branchInfo: { ...defaultBranchInfo, currentIndex: 1 }
}
});
expect(screen.getByText('2/3')).toBeDefined();
rerender({
branchInfo: { ...defaultBranchInfo, currentIndex: 2 }
});
expect(screen.getByText('3/3')).toBeDefined();
});
it('handles keyboard navigation with left arrow', async () => {
const onSwitch = vi.fn();
render(BranchNavigator, {
props: {
branchInfo: defaultBranchInfo,
onSwitch
}
});
const nav = screen.getByRole('navigation');
await fireEvent.keyDown(nav, { key: 'ArrowLeft' });
expect(onSwitch).toHaveBeenCalledWith('prev');
});
it('handles keyboard navigation with right arrow', async () => {
const onSwitch = vi.fn();
render(BranchNavigator, {
props: {
branchInfo: defaultBranchInfo,
onSwitch
}
});
const nav = screen.getByRole('navigation');
await fireEvent.keyDown(nav, { key: 'ArrowRight' });
expect(onSwitch).toHaveBeenCalledWith('next');
});
it('is focusable for keyboard navigation', () => {
render(BranchNavigator, {
props: {
branchInfo: defaultBranchInfo
}
});
const nav = screen.getByRole('navigation');
expect(nav.getAttribute('tabindex')).toBe('0');
});
it('shows correct count for single message', () => {
render(BranchNavigator, {
props: {
branchInfo: {
currentIndex: 0,
totalCount: 1,
siblingIds: ['msg-1']
}
}
});
expect(screen.getByText('1/1')).toBeDefined();
});
});

View File

@@ -4,11 +4,13 @@
* Handles sending messages, streaming responses, and tool execution
*/
import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState } from '$lib/stores';
import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState, agentsState } from '$lib/stores';
import { backendsState } from '$lib/stores/backends.svelte';
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 { unifiedLLMClient, type ChatMessage as UnifiedChatMessage } from '$lib/llm';
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';
@@ -34,6 +36,7 @@
import SummaryBanner from './SummaryBanner.svelte';
import StreamingStats from './StreamingStats.svelte';
import SystemPromptSelector from './SystemPromptSelector.svelte';
import AgentSelector from './AgentSelector.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';
@@ -89,6 +92,9 @@
// System prompt for new conversations (before a conversation is created)
let newChatPromptId = $state<string | null>(null);
// Agent for new conversations (before a conversation is created)
let newChatAgentId = $state<string | null>(null);
// File picker trigger function (bound from ChatInput -> FileUpload)
let triggerFilePicker: (() => void) | undefined = $state();
@@ -229,9 +235,18 @@
/**
* Get tool definitions for the API call
* If an agent is selected, only returns tools the agent has enabled
*/
function getToolsForApi(): OllamaToolDefinition[] | undefined {
if (!toolsState.toolsEnabled) return undefined;
// If an agent is selected, filter tools by agent's enabled list
if (currentAgent) {
const tools = toolsState.getToolDefinitionsForAgent(currentAgent.enabledToolNames);
return tools.length > 0 ? tools as OllamaToolDefinition[] : undefined;
}
// No agent - use all enabled tools
const tools = toolsState.getEnabledToolDefinitions();
return tools.length > 0 ? tools as OllamaToolDefinition[] : undefined;
}
@@ -239,6 +254,13 @@
// Derived: Check if there are any messages
const hasMessages = $derived(chatState.visibleMessages.length > 0);
// Derived: Current agent (from conversation or new chat selection)
const currentAgent = $derived.by(() => {
const agentId = mode === 'conversation' ? conversation?.agentId : newChatAgentId;
if (!agentId) return null;
return agentsState.get(agentId) ?? null;
});
// Update context manager when model changes
$effect(() => {
const model = modelsState.selectedId;
@@ -510,11 +532,33 @@
await sendMessageInternal(content, images, attachments);
}
/**
* Get current model name based on active backend
*/
async function getCurrentModelName(): Promise<string | null> {
if (backendsState.activeType === 'ollama') {
return modelsState.selectedId;
} else if (backendsState.activeType === 'llamacpp' || backendsState.activeType === 'lmstudio') {
try {
const response = await fetch('/api/v1/ai/models');
if (response.ok) {
const data = await response.json();
if (data.models && data.models.length > 0) {
return data.models[0].name;
}
}
} catch (err) {
console.error('Failed to get model from backend:', err);
}
}
return null;
}
/**
* Internal: Send message and stream response (bypasses context check)
*/
async function sendMessageInternal(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
const selectedModel = modelsState.selectedId;
const selectedModel = await getCurrentModelName();
if (!selectedModel) return;
// In 'new' mode with no messages yet, create conversation first
@@ -725,15 +769,18 @@
// 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
// 3. Agent prompt (if agent selected)
// 4. Model-prompt mapping
// 5. Model-embedded prompt (from Modelfile)
// 6. Capability-matched prompt
// 7. Global active prompt
// 8. None
const resolvedPrompt = await resolveSystemPrompt(
model,
conversation?.systemPromptId,
newChatPromptId
newChatPromptId,
currentAgent?.promptId,
currentAgent?.name
);
if (resolvedPrompt.content) {
@@ -784,7 +831,91 @@
let streamingThinking = '';
let thinkingClosed = false;
await ollamaClient.streamChatWithCallbacks(
// Common completion handler for both clients
const handleStreamComplete = async () => {
// Close thinking block if it was opened but not closed (e.g., tool calls without content)
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\n\n');
thinkingClosed = true;
}
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
// Handle native tool calls if received (Ollama only)
if (pendingToolCalls && pendingToolCalls.length > 0) {
await executeToolsAndContinue(
model,
assistantMessageId,
pendingToolCalls,
conversationId
);
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 nodeForPersist = chatState.messageTree.get(assistantMessageId);
if (nodeForPersist) {
await addStoredMessage(
conversationId,
{ role: 'assistant', content: nodeForPersist.message.content },
parentMessageId,
assistantMessageId
);
await updateConversation(conversationId, {});
conversationsState.update(conversationId, {});
}
}
// Check for auto-compact after response completes
await handleAutoCompact();
};
// Common error handler for both clients
const handleStreamError = (error: unknown) => {
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;
};
// Use appropriate client based on active backend
if (backendsState.activeType === 'ollama') {
// Ollama - full feature support (thinking, native tool calls)
await ollamaClient.streamChatWithCallbacks(
{
model: chatModel,
messages,
@@ -828,86 +959,42 @@
// Store tool calls to process after streaming completes
pendingToolCalls = toolCalls;
},
onComplete: async () => {
// Close thinking block if it was opened but not closed (e.g., tool calls without content)
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\n\n');
thinkingClosed = true;
}
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
// Handle native tool calls if received
if (pendingToolCalls && pendingToolCalls.length > 0) {
await executeToolsAndContinue(
model,
assistantMessageId,
pendingToolCalls,
conversationId
);
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 nodeForPersist = chatState.messageTree.get(assistantMessageId);
if (nodeForPersist) {
await addStoredMessage(
conversationId,
{ role: 'assistant', content: nodeForPersist.message.content },
parentMessageId,
assistantMessageId
);
await updateConversation(conversationId, {});
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;
}
onComplete: handleStreamComplete,
onError: handleStreamError
},
abortController.signal
);
} else {
// llama.cpp / LM Studio - basic streaming via unified API
const unifiedMessages: UnifiedChatMessage[] = messages.map(m => ({
role: m.role as 'system' | 'user' | 'assistant' | 'tool',
content: m.content,
images: m.images
}));
await unifiedLLMClient.streamChatWithCallbacks(
{
model: chatModel,
messages: unifiedMessages,
options: settingsState.apiParameters
},
{
onToken: (token) => {
// Clear "Processing..." on first token
if (needsClearOnFirstToken) {
chatState.setStreamContent('');
needsClearOnFirstToken = false;
}
chatState.appendToStreaming(token);
// Track content tokens for metrics
streamingMetricsState.incrementTokens();
},
onComplete: handleStreamComplete,
onError: handleStreamError
},
abortController.signal
);
}
} catch (error) {
console.error('Failed to send message:', error);
// Show error to user
@@ -1281,6 +1368,19 @@
onSelect={(promptId) => (newChatPromptId = promptId)}
/>
{/if}
<!-- Agent selector -->
{#if mode === 'conversation' && conversation}
<AgentSelector
conversationId={conversation.id}
currentAgentId={conversation.agentId}
/>
{:else if mode === 'new'}
<AgentSelector
currentAgentId={newChatAgentId}
onSelect={(agentId) => (newChatAgentId = agentId)}
/>
{/if}
</div>
<!-- Right side: Attach files + Thinking mode toggle -->
@@ -1310,6 +1410,7 @@
type="button"
role="switch"
aria-checked={thinkingEnabled}
aria-label="Toggle thinking mode"
onclick={() => (thinkingEnabled = !thinkingEnabled)}
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-theme-primary {thinkingEnabled ? 'bg-amber-600' : 'bg-theme-tertiary'}"
>

View File

@@ -13,12 +13,25 @@
height?: number;
}
const { html, title = 'Preview', height = 300 }: Props = $props();
const props: Props = $props();
// Derive values from props
const html = $derived(props.html);
const title = $derived(props.title ?? 'Preview');
const height = $derived(props.height ?? 300);
// State
let iframeRef: HTMLIFrameElement | null = $state(null);
let isExpanded = $state(false);
let actualHeight = $state(height);
// actualHeight tracks the current display height, synced from prop when not expanded
let actualHeight = $state(props.height ?? 300);
// Sync actualHeight when height prop changes (only when not expanded)
$effect(() => {
if (!isExpanded) {
actualHeight = height;
}
});
// Generate a complete HTML document if the code is just a fragment
const fullHtml = $derived.by(() => {

View File

@@ -211,10 +211,10 @@
</svg>
</button>
<!-- Dropdown menu -->
<!-- Dropdown menu (opens upward) -->
{#if isOpen}
<div
class="absolute left-0 top-full z-50 mt-1 w-72 rounded-lg border border-theme bg-theme-secondary py-1 shadow-xl"
class="absolute bottom-full left-0 z-50 mb-1 max-h-80 w-72 overflow-y-auto rounded-lg border border-theme bg-theme-secondary py-1 shadow-xl"
>
<!-- Model default section -->
<div class="px-3 py-1.5 text-xs font-medium text-theme-muted uppercase tracking-wide">

View File

@@ -14,9 +14,15 @@
inProgress?: boolean;
}
const { content, defaultExpanded = false, inProgress = false }: Props = $props();
const props: Props = $props();
let isExpanded = $state(defaultExpanded);
// Initialize isExpanded from defaultExpanded prop
// This intentionally captures the initial value only - user controls expansion independently
let isExpanded = $state(props.defaultExpanded ?? false);
// Derived values from props for reactivity
const content = $derived(props.content);
const inProgress = $derived(props.inProgress ?? false);
// Keep collapsed during and after streaming - user can expand manually if desired

View File

@@ -0,0 +1,121 @@
/**
* ThinkingBlock component tests
*
* Tests the collapsible thinking/reasoning display component
*/
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import ThinkingBlock from './ThinkingBlock.svelte';
describe('ThinkingBlock', () => {
it('renders collapsed by default', () => {
render(ThinkingBlock, {
props: {
content: 'Some thinking content'
}
});
// Should show the header
expect(screen.getByText('Reasoning')).toBeDefined();
// Content should not be visible when collapsed
expect(screen.queryByText('Some thinking content')).toBeNull();
});
it('renders expanded when defaultExpanded is true', () => {
render(ThinkingBlock, {
props: {
content: 'Some thinking content',
defaultExpanded: true
}
});
// Content should be visible when expanded
// The content is rendered as HTML, so we check for the container
const content = screen.getByText(/Click to collapse/);
expect(content).toBeDefined();
});
it('toggles expand/collapse on click', async () => {
render(ThinkingBlock, {
props: {
content: 'Toggle content'
}
});
// Initially collapsed
expect(screen.getByText('Click to expand')).toBeDefined();
// Click to expand
const button = screen.getByRole('button');
await fireEvent.click(button);
// Should show collapse option
expect(screen.getByText('Click to collapse')).toBeDefined();
// Click to collapse
await fireEvent.click(button);
// Should show expand option again
expect(screen.getByText('Click to expand')).toBeDefined();
});
it('shows thinking indicator when in progress', () => {
render(ThinkingBlock, {
props: {
content: 'Current thinking...',
inProgress: true
}
});
expect(screen.getByText('Thinking...')).toBeDefined();
});
it('shows reasoning text when not in progress', () => {
render(ThinkingBlock, {
props: {
content: 'Completed thoughts',
inProgress: false
}
});
expect(screen.getByText('Reasoning')).toBeDefined();
});
it('shows brain emoji when not in progress', () => {
render(ThinkingBlock, {
props: {
content: 'Content',
inProgress: false
}
});
// The brain emoji is rendered as text
const brainEmoji = screen.queryByText('🧠');
expect(brainEmoji).toBeDefined();
});
it('has appropriate styling when in progress', () => {
const { container } = render(ThinkingBlock, {
props: {
content: 'In progress content',
inProgress: true
}
});
// Should have ring class for in-progress state
const wrapper = container.querySelector('.ring-1');
expect(wrapper).toBeDefined();
});
it('button is accessible', () => {
render(ThinkingBlock, {
props: {
content: 'Accessible content'
}
});
const button = screen.getByRole('button');
expect(button.getAttribute('type')).toBe('button');
});
});

View File

@@ -98,14 +98,18 @@
<!-- Chat icon -->
<div class="mt-0.5 shrink-0">
{#if conversation.isPinned}
<!-- Pin icon for pinned conversations -->
<!-- Bookmark icon for pinned conversations -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-emerald-500"
viewBox="0 0 20 20"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" />
<path
fill-rule="evenodd"
d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z"
clip-rule="evenodd"
/>
</svg>
{:else}
<!-- Regular chat bubble -->
@@ -147,49 +151,36 @@
</div>
<!-- Action buttons (always visible on mobile, hover on desktop) -->
<div class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1 transition-opacity {uiState.isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}">
<div class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-theme-secondary/90 px-1 py-0.5 shadow-sm transition-opacity {uiState.isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}">
<!-- Pin/Unpin button -->
<button
type="button"
onclick={handlePin}
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
class="rounded p-1 transition-colors hover:bg-theme-tertiary {conversation.isPinned ? 'text-emerald-500 hover:text-emerald-400' : 'text-theme-secondary hover:text-theme-primary'}"
aria-label={conversation.isPinned ? 'Unpin conversation' : 'Pin conversation'}
title={conversation.isPinned ? 'Unpin' : 'Pin'}
>
{#if conversation.isPinned}
<!-- Unpin icon (filled) -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M8.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z" />
</svg>
{:else}
<!-- Pin icon (outline) -->
<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="M3 6.75A.75.75 0 0 1 3.75 6h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 6.75ZM3 12a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 12Zm0 5.25a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1-.75-.75Z"
/>
</svg>
{/if}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill={conversation.isPinned ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
/>
</svg>
</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"
class="rounded p-1 text-theme-secondary transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Move to project"
title="Move to project"
>
@@ -213,7 +204,7 @@
<button
type="button"
onclick={handleExport}
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
class="rounded p-1 text-theme-secondary transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Export conversation"
title="Export"
>
@@ -237,7 +228,7 @@
<button
type="button"
onclick={handleDelete}
class="rounded p-1 text-theme-muted transition-colors hover:bg-red-900/50 hover:text-red-400"
class="rounded p-1 text-theme-secondary transition-colors hover:bg-red-900/50 hover:text-red-400"
aria-label="Delete conversation"
title="Delete"
>

View File

@@ -98,7 +98,7 @@
<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"
class="shrink-0 rounded p-1 text-theme-secondary opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-theme-primary group-hover:opacity-100"
aria-label="Project settings"
title="Settings"
>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
/**
* SyncStatusIndicator.svelte - Compact sync status indicator for TopNav
* Shows connection status with backend: synced, syncing, error, or offline
*/
import { syncState } from '$lib/backend';
/** Computed status for display */
let displayStatus = $derived.by(() => {
if (syncState.status === 'offline' || !syncState.isOnline) {
return 'offline';
}
if (syncState.status === 'error') {
return 'error';
}
if (syncState.status === 'syncing') {
return 'syncing';
}
return 'synced';
});
/** Tooltip text based on status */
let tooltipText = $derived.by(() => {
switch (displayStatus) {
case 'offline':
return 'Backend offline - data stored locally only';
case 'error':
return syncState.lastError
? `Sync error: ${syncState.lastError}`
: 'Sync error - check backend connection';
case 'syncing':
return 'Syncing...';
case 'synced':
if (syncState.lastSyncTime) {
const ago = getTimeAgo(syncState.lastSyncTime);
return `Synced ${ago}`;
}
return 'Synced';
}
});
/** Format relative time */
function getTimeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
</script>
<div class="relative flex items-center" title={tooltipText}>
<!-- Status dot -->
<span
class="inline-block h-2 w-2 rounded-full {displayStatus === 'synced'
? 'bg-emerald-500'
: displayStatus === 'syncing'
? 'animate-pulse bg-amber-500'
: 'bg-red-500'}"
aria-hidden="true"
></span>
<!-- Pending count badge (only when error/offline with pending items) -->
{#if (displayStatus === 'error' || displayStatus === 'offline') && syncState.pendingCount > 0}
<span
class="ml-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-500"
>
{syncState.pendingCount}
</span>
{/if}
</div>

View File

@@ -9,6 +9,7 @@
import ExportDialog from '$lib/components/shared/ExportDialog.svelte';
import ConfirmDialog from '$lib/components/shared/ConfirmDialog.svelte';
import ContextUsageBar from '$lib/components/chat/ContextUsageBar.svelte';
import SyncStatusIndicator from './SyncStatusIndicator.svelte';
interface Props {
/** Slot for the model select dropdown */
@@ -167,8 +168,13 @@
</div>
{/if}
<!-- Right section: Theme toggle + Chat actions -->
<!-- Right section: Sync status + Theme toggle + Chat actions -->
<div class="flex items-center gap-1">
<!-- Sync status indicator (always visible) -->
<div class="mr-1 px-2">
<SyncStatusIndicator />
</div>
<!-- Theme toggle (always visible) -->
<button
type="button"

View File

@@ -109,9 +109,11 @@
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="model-editor-title"
tabindex="-1"
>
<!-- Dialog -->
<div class="w-full max-w-lg rounded-xl bg-theme-secondary shadow-xl">

View File

@@ -40,9 +40,11 @@
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="pull-dialog-title"
tabindex="-1"
>
<!-- Dialog -->
<div class="w-full max-w-md rounded-xl bg-theme-secondary p-6 shadow-xl">

View File

@@ -71,9 +71,11 @@
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="move-dialog-title"
tabindex="-1"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-sm rounded-xl border border-theme bg-theme-primary shadow-2xl">

View File

@@ -5,6 +5,7 @@
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';
import { ConfirmDialog } from '$lib/components/shared';
interface Props {
isOpen: boolean;
@@ -26,6 +27,7 @@
let newLinkDescription = $state('');
let isLoading = $state(false);
let activeTab = $state<'settings' | 'instructions' | 'links'>('settings');
let showDeleteConfirm = $state(false);
// Predefined colors for quick selection
const presetColors = [
@@ -121,13 +123,14 @@
}
}
async function handleDelete() {
function handleDeleteClick() {
if (!projectId) return;
showDeleteConfirm = true;
}
if (!confirm('Delete this project? Conversations will be unlinked but not deleted.')) {
return;
}
async function handleDeleteConfirm() {
if (!projectId) return;
showDeleteConfirm = false;
isLoading = true;
try {
@@ -207,9 +210,11 @@
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
tabindex="-1"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-lg rounded-xl border border-theme bg-theme-primary shadow-2xl">
@@ -310,9 +315,9 @@
<!-- Color -->
<div>
<label class="mb-1.5 block text-sm font-medium text-theme-secondary">
<span class="mb-1.5 block text-sm font-medium text-theme-secondary">
Color
</label>
</span>
<div class="flex items-center gap-2">
{#each presetColors as presetColor}
<button
@@ -429,7 +434,7 @@
{#if projectId}
<button
type="button"
onclick={handleDelete}
onclick={handleDeleteClick}
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"
>
@@ -458,3 +463,13 @@
</div>
</div>
{/if}
<ConfirmDialog
isOpen={showDeleteConfirm}
title="Delete Project"
message="Delete this project? Conversations will be unlinked but not deleted."
confirmText="Delete"
variant="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => (showDeleteConfirm = false)}
/>

View File

@@ -0,0 +1,74 @@
<script lang="ts">
/**
* AIProvidersTab - Combined Backends and Models management
* Sub-tabs for backend configuration and model management
* Models sub-tab only available when Ollama is active
*/
import { backendsState } from '$lib/stores/backends.svelte';
import BackendsPanel from './BackendsPanel.svelte';
import ModelsTab from './ModelsTab.svelte';
type SubTab = 'backends' | 'models';
let activeSubTab = $state<SubTab>('backends');
// Models tab only available for Ollama
const isOllamaActive = $derived(backendsState.activeType === 'ollama');
// If Models tab is active but Ollama is no longer active, switch to Backends
$effect(() => {
if (activeSubTab === 'models' && !isOllamaActive) {
activeSubTab = 'backends';
}
});
</script>
<div class="space-y-6">
<!-- Sub-tab Navigation -->
<div class="flex gap-1 border-b border-theme">
<button
type="button"
onclick={() => (activeSubTab = 'backends')}
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSubTab === 'backends'
? 'border-violet-500 text-violet-400'
: 'border-transparent text-theme-muted hover:border-theme 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="M5 12h14M5 12a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2M5 12a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2m-2-4h.01M17 16h.01" />
</svg>
Backends
</button>
{#if isOllamaActive}
<button
type="button"
onclick={() => (activeSubTab = 'models')}
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSubTab === 'models'
? 'border-violet-500 text-violet-400'
: 'border-transparent text-theme-muted hover:border-theme 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="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>
Models
</button>
{:else}
<span
class="flex cursor-not-allowed items-center gap-2 border-b-2 border-transparent px-4 py-2 text-sm font-medium text-theme-muted/50"
title="Models tab only available when Ollama is the active backend"
>
<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>
Models
<span class="text-xs">(Ollama only)</span>
</span>
{/if}
</div>
<!-- Sub-tab Content -->
{#if activeSubTab === 'backends'}
<BackendsPanel />
{:else if activeSubTab === 'models'}
<ModelsTab />
{/if}
</div>

View File

@@ -0,0 +1,194 @@
<script lang="ts">
/**
* AboutTab - App information, version, and update status
*/
import { versionState } from '$lib/stores';
const GITHUB_URL = 'https://github.com/VikingOwl91/vessel';
const ISSUES_URL = `${GITHUB_URL}/issues`;
const LICENSE = 'MIT';
async function handleCheckForUpdates(): Promise<void> {
await versionState.checkForUpdates();
}
function formatLastChecked(timestamp: number): string {
if (!timestamp) return 'Never';
const date = new Date(timestamp);
return date.toLocaleString();
}
</script>
<div class="space-y-8">
<!-- App Identity -->
<section>
<div class="flex items-center gap-6">
<div class="flex h-20 w-20 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-indigo-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" viewBox="0 0 24 24">
<path d="M12 20 L4 6 Q4 5 5 5 L8 5 L12 12.5 L16 5 L19 5 Q20 5 20 6 L12 20 Z" fill="white"/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-theme-primary">Vessel</h1>
<p class="mt-1 text-theme-muted">
A modern interface for local AI with chat, tools, and memory management.
</p>
{#if versionState.current}
<div class="mt-2 flex items-center gap-2">
<span class="rounded-full bg-emerald-500/20 px-3 py-0.5 text-sm font-medium text-emerald-400">
v{versionState.current}
</span>
</div>
{/if}
</div>
</div>
</section>
<!-- Version & Updates -->
<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Updates
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
{#if versionState.hasUpdate}
<div class="flex items-start gap-3 rounded-lg bg-amber-500/10 border border-amber-500/30 p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
<div class="flex-1">
<p class="font-medium text-amber-200">Update Available</p>
<p class="text-sm text-amber-300/80">
Version {versionState.latest} is available. You're currently on v{versionState.current}.
</p>
</div>
</div>
{:else}
<div class="flex items-center gap-3">
<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="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="text-sm text-theme-secondary">You're running the latest version</span>
</div>
{/if}
<div class="flex flex-wrap gap-3">
<button
type="button"
onclick={handleCheckForUpdates}
disabled={versionState.isChecking}
class="flex items-center gap-2 rounded-lg bg-theme-tertiary px-4 py-2 text-sm font-medium text-theme-secondary transition-colors hover:bg-theme-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if versionState.isChecking}
<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>
Checking...
{: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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Check for Updates
{/if}
</button>
{#if versionState.hasUpdate && versionState.updateUrl}
<a
href={versionState.updateUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-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="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download v{versionState.latest}
</a>
{/if}
</div>
{#if versionState.lastChecked}
<p class="text-xs text-theme-muted">
Last checked: {formatLastChecked(versionState.lastChecked)}
</p>
{/if}
</div>
</section>
<!-- Links -->
<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="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
Links
</h2>
<div class="grid gap-3 sm:grid-cols-2">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:bg-theme-tertiary"
>
<svg class="h-6 w-6 text-theme-secondary" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
</svg>
<div>
<p class="font-medium text-theme-primary">GitHub Repository</p>
<p class="text-xs text-theme-muted">Source code and releases</p>
</div>
</a>
<a
href={ISSUES_URL}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:bg-theme-tertiary"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-theme-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0 1 12 12.75Zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 0 1-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 0 0 2.248-2.354M12 12.75a2.25 2.25 0 0 1-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 0 0-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 0 1 .4-2.253M12 8.25a2.25 2.25 0 0 0-2.248 2.146M12 8.25a2.25 2.25 0 0 1 2.248 2.146M8.683 5a6.032 6.032 0 0 1-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0 1 15.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 0 0-.575-1.752M4.921 6a24.048 24.048 0 0 0-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 0 1-5.223 1.082" />
</svg>
<div>
<p class="font-medium text-theme-primary">Report an Issue</p>
<p class="text-xs text-theme-muted">Bug reports and feature requests</p>
</div>
</a>
</div>
</section>
<!-- Tech Stack & License -->
<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-teal-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
Technical Info
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<div>
<p class="text-sm font-medium text-theme-secondary">Built With</p>
<div class="mt-2 flex flex-wrap gap-2">
<span class="rounded-full bg-orange-500/20 px-3 py-1 text-xs font-medium text-orange-300">Svelte 5</span>
<span class="rounded-full bg-blue-500/20 px-3 py-1 text-xs font-medium text-blue-300">SvelteKit</span>
<span class="rounded-full bg-cyan-500/20 px-3 py-1 text-xs font-medium text-cyan-300">Go</span>
<span class="rounded-full bg-sky-500/20 px-3 py-1 text-xs font-medium text-sky-300">Tailwind CSS</span>
<span class="rounded-full bg-emerald-500/20 px-3 py-1 text-xs font-medium text-emerald-300">Ollama</span>
<span class="rounded-full bg-purple-500/20 px-3 py-1 text-xs font-medium text-purple-300">llama.cpp</span>
</div>
</div>
<div class="border-t border-theme pt-4">
<p class="text-sm font-medium text-theme-secondary">License</p>
<p class="mt-1 text-sm text-theme-muted">
Released under the <span class="text-theme-secondary">{LICENSE}</span> license
</p>
</div>
</div>
</section>
</div>

View File

@@ -0,0 +1,500 @@
<script lang="ts">
/**
* AgentsTab - Agent management settings tab
* CRUD operations for agents with prompt and tool configuration
*/
import { agentsState, promptsState, toolsState } from '$lib/stores';
import type { Agent } from '$lib/storage';
import { ConfirmDialog } from '$lib/components/shared';
let showEditor = $state(false);
let editingAgent = $state<Agent | null>(null);
let searchQuery = $state('');
let deleteConfirm = $state<{ show: boolean; agent: Agent | null }>({ show: false, agent: null });
// Form state
let formName = $state('');
let formDescription = $state('');
let formPromptId = $state<string | null>(null);
let formPreferredModel = $state<string | null>(null);
let formEnabledTools = $state<Set<string>>(new Set());
// Stats
const stats = $derived({
total: agentsState.agents.length
});
// Filtered agents based on search
const filteredAgents = $derived(
searchQuery.trim()
? agentsState.sortedAgents.filter(
(a) =>
a.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
a.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: agentsState.sortedAgents
);
// Available tools for selection
const availableTools = $derived(
toolsState.getAllToolsWithState().map((t) => ({
name: t.definition.function.name,
description: t.definition.function.description,
isBuiltin: t.isBuiltin
}))
);
function openCreateEditor(): void {
editingAgent = null;
formName = '';
formDescription = '';
formPromptId = null;
formPreferredModel = null;
formEnabledTools = new Set();
showEditor = true;
}
function openEditEditor(agent: Agent): void {
editingAgent = agent;
formName = agent.name;
formDescription = agent.description;
formPromptId = agent.promptId;
formPreferredModel = agent.preferredModel;
formEnabledTools = new Set(agent.enabledToolNames);
showEditor = true;
}
function closeEditor(): void {
showEditor = false;
editingAgent = null;
}
async function handleSave(): Promise<void> {
if (!formName.trim()) return;
const data = {
name: formName.trim(),
description: formDescription.trim(),
promptId: formPromptId,
preferredModel: formPreferredModel,
enabledToolNames: Array.from(formEnabledTools)
};
if (editingAgent) {
await agentsState.update(editingAgent.id, data);
} else {
await agentsState.add(data);
}
closeEditor();
}
function handleDelete(agent: Agent): void {
deleteConfirm = { show: true, agent };
}
async function confirmDelete(): Promise<void> {
if (deleteConfirm.agent) {
await agentsState.remove(deleteConfirm.agent.id);
}
deleteConfirm = { show: false, agent: null };
}
function toggleTool(toolName: string): void {
const newSet = new Set(formEnabledTools);
if (newSet.has(toolName)) {
newSet.delete(toolName);
} else {
newSet.add(toolName);
}
formEnabledTools = newSet;
}
function getPromptName(promptId: string | null): string {
if (!promptId) return 'No prompt';
const prompt = promptsState.get(promptId);
return prompt?.name ?? 'Unknown prompt';
}
</script>
<div>
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-theme-primary">Agents</h2>
<p class="mt-1 text-sm text-theme-muted">
Create specialized agents with custom prompts and tool sets
</p>
</div>
<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 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 Agent
</button>
</div>
<!-- Stats -->
<div class="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted">Total Agents</p>
<p class="mt-1 text-2xl font-semibold text-theme-primary">{stats.total}</p>
</div>
</div>
<!-- Search -->
{#if agentsState.agents.length > 0}
<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 agents..."
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"
aria-label="Clear search"
>
<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>
{/if}
<!-- Agents List -->
{#if filteredAgents.length === 0 && agentsState.agents.length === 0}
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
<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="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
/>
</svg>
<h4 class="mt-4 text-sm font-medium text-theme-secondary">No agents yet</h4>
<p class="mt-1 text-sm text-theme-muted">
Create agents to combine prompts and tools for specialized tasks
</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 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 Agent
</button>
</div>
{:else if filteredAgents.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 agents match your search</p>
</div>
{:else}
<div class="space-y-3">
{#each filteredAgents as agent (agent.id)}
<div class="rounded-lg border border-theme bg-theme-secondary">
<div class="p-4">
<div class="flex items-start gap-4">
<!-- Agent Icon -->
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-violet-900/30 text-violet-400"
>
<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 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
/>
</svg>
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-theme-primary">{agent.name}</h4>
{#if agent.promptId}
<span class="rounded-full bg-blue-900/40 px-2 py-0.5 text-xs font-medium text-blue-300">
{getPromptName(agent.promptId)}
</span>
{/if}
{#if agent.enabledToolNames.length > 0}
<span
class="rounded-full bg-emerald-900/40 px-2 py-0.5 text-xs font-medium text-emerald-300"
>
{agent.enabledToolNames.length} tools
</span>
{/if}
</div>
{#if agent.description}
<p class="mt-1 text-sm text-theme-muted line-clamp-2">
{agent.description}
</p>
{/if}
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => openEditEditor(agent)}
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Edit agent"
>
<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={() => handleDelete(agent)}
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
aria-label="Delete agent"
>
<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>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
<!-- 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 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>
About Agents
</h4>
<p class="mt-3 text-sm leading-relaxed text-theme-muted">
Agents combine a system prompt with a specific set of tools. When you select an agent for a
chat, it will use the agent's prompt and only have access to the agent's allowed tools.
</p>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="rounded-lg bg-theme-tertiary/50 p-3">
<div class="flex items-center gap-2 text-xs font-medium text-blue-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="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>
System Prompt
</div>
<p class="mt-1 text-xs text-theme-muted">Defines the agent's personality and behavior</p>
</div>
<div class="rounded-lg bg-theme-tertiary/50 p-3">
<div class="flex items-center gap-2 text-xs font-medium text-emerald-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="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>
Tool Access
</div>
<p class="mt-1 text-xs text-theme-muted">Restricts which tools the agent can use</p>
</div>
</div>
</section>
</div>
<!-- Editor Dialog -->
{#if showEditor}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="agent-editor-title"
>
<div class="w-full max-w-2xl 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">
<h3 id="agent-editor-title" class="text-lg font-semibold text-theme-primary">
{editingAgent ? 'Edit Agent' : 'Create Agent'}
</h3>
<button
type="button"
onclick={closeEditor}
class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Close"
>
<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 -->
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
class="max-h-[70vh] overflow-y-auto p-6"
>
<!-- Name -->
<div class="mb-4">
<label for="agent-name" class="mb-1 block text-sm font-medium text-theme-primary">
Name <span class="text-red-400">*</span>
</label>
<input
id="agent-name"
type="text"
bind:value={formName}
placeholder="e.g., Research Assistant"
required
class="w-full rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary placeholder:text-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
</div>
<!-- Description -->
<div class="mb-4">
<label for="agent-description" class="mb-1 block text-sm font-medium text-theme-primary">
Description
</label>
<textarea
id="agent-description"
bind:value={formDescription}
placeholder="Describe what this agent does..."
rows={3}
class="w-full rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary placeholder:text-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
></textarea>
</div>
<!-- Prompt Selection -->
<div class="mb-4">
<label for="agent-prompt" class="mb-1 block text-sm font-medium text-theme-primary">
System Prompt
</label>
<select
id="agent-prompt"
bind:value={formPromptId}
class="w-full rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value={null}>No specific prompt (use defaults)</option>
{#each promptsState.prompts as prompt (prompt.id)}
<option value={prompt.id}>{prompt.name}</option>
{/each}
</select>
<p class="mt-1 text-xs text-theme-muted">
Select a prompt from your library to use with this agent
</p>
</div>
<!-- Tools Selection -->
<div class="mb-4">
<span class="mb-2 block text-sm font-medium text-theme-primary"> Allowed Tools </span>
<div class="max-h-48 overflow-y-auto rounded-lg border border-theme bg-theme-secondary p-2">
{#if availableTools.length === 0}
<p class="p-2 text-sm text-theme-muted">No tools available</p>
{:else}
<div class="space-y-1">
{#each availableTools as tool (tool.name)}
<label
class="flex cursor-pointer items-center gap-2 rounded p-2 hover:bg-theme-tertiary"
>
<input
type="checkbox"
checked={formEnabledTools.has(tool.name)}
onchange={() => toggleTool(tool.name)}
class="h-4 w-4 rounded border-gray-600 bg-theme-tertiary text-violet-500 focus:ring-violet-500"
/>
<span class="text-sm text-theme-primary">{tool.name}</span>
{#if tool.isBuiltin}
<span class="text-xs text-blue-400">(built-in)</span>
{/if}
</label>
{/each}
</div>
{/if}
</div>
<p class="mt-1 text-xs text-theme-muted">
{formEnabledTools.size === 0
? 'All tools will be available (no restrictions)'
: `${formEnabledTools.size} tool(s) selected`}
</p>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={closeEditor}
class="rounded-lg border border-theme px-4 py-2 text-sm font-medium text-theme-secondary transition-colors hover:bg-theme-tertiary"
>
Cancel
</button>
<button
type="submit"
disabled={!formName.trim()}
class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{editingAgent ? 'Save Changes' : 'Create Agent'}
</button>
</div>
</form>
</div>
</div>
{/if}
<ConfirmDialog
isOpen={deleteConfirm.show}
title="Delete Agent"
message={`Delete "${deleteConfirm.agent?.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={confirmDelete}
onCancel={() => (deleteConfirm = { show: false, agent: null })}
/>

View File

@@ -0,0 +1,305 @@
<script lang="ts">
/**
* BackendsPanel - Multi-backend LLM management
* Configure and switch between Ollama, llama.cpp, and LM Studio
*/
import { onMount } from 'svelte';
import { backendsState, type BackendType, type BackendInfo, type DiscoveryResult } from '$lib/stores/backends.svelte';
let discovering = $state(false);
let discoveryResults = $state<DiscoveryResult[]>([]);
let showDiscoveryResults = $state(false);
async function handleDiscover(): Promise<void> {
discovering = true;
showDiscoveryResults = false;
try {
discoveryResults = await backendsState.discover();
showDiscoveryResults = true;
// Reload backends after discovery
await backendsState.load();
} finally {
discovering = false;
}
}
async function handleSetActive(type: BackendType): Promise<void> {
await backendsState.setActive(type);
}
function getBackendDisplayName(type: BackendType): string {
switch (type) {
case 'ollama':
return 'Ollama';
case 'llamacpp':
return 'llama.cpp';
case 'lmstudio':
return 'LM Studio';
default:
return type;
}
}
function getBackendDescription(type: BackendType): string {
switch (type) {
case 'ollama':
return 'Full model management - pull, delete, create custom models';
case 'llamacpp':
return 'OpenAI-compatible API - models loaded at server startup';
case 'lmstudio':
return 'OpenAI-compatible API - manage models via LM Studio app';
default:
return '';
}
}
function getDefaultPort(type: BackendType): string {
switch (type) {
case 'ollama':
return '11434';
case 'llamacpp':
return '8081';
case 'lmstudio':
return '1234';
default:
return '';
}
}
function getStatusColor(status: string): string {
switch (status) {
case 'connected':
return 'bg-green-500';
case 'disconnected':
return 'bg-red-500';
default:
return 'bg-yellow-500';
}
}
onMount(() => {
backendsState.load();
});
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-xl font-bold text-theme-primary">AI Backends</h2>
<p class="mt-1 text-sm text-theme-muted">
Configure LLM backends: Ollama, llama.cpp server, or LM Studio
</p>
</div>
<button
type="button"
onclick={handleDiscover}
disabled={discovering}
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if discovering}
<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>
<span>Discovering...</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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Auto-Detect</span>
{/if}
</button>
</div>
<!-- Error Message -->
{#if backendsState.error}
<div class="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>{backendsState.error}</span>
<button type="button" onclick={() => backendsState.clearError()} class="ml-auto text-red-400 hover:text-red-300" aria-label="Dismiss error">
<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}
<!-- Discovery Results -->
{#if showDiscoveryResults && discoveryResults.length > 0}
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<h3 class="mb-3 text-sm font-medium text-theme-secondary">Discovery Results</h3>
<div class="space-y-2">
{#each discoveryResults as result}
<div class="flex items-center justify-between rounded-lg bg-theme-tertiary/50 px-3 py-2">
<div class="flex items-center gap-3">
<span class="h-2 w-2 rounded-full {result.available ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-sm text-theme-primary">{getBackendDisplayName(result.type)}</span>
<span class="text-xs text-theme-muted">{result.baseUrl}</span>
</div>
<span class="text-xs {result.available ? 'text-green-400' : 'text-red-400'}">
{result.available ? 'Available' : result.error || 'Not found'}
</span>
</div>
{/each}
</div>
<button
type="button"
onclick={() => showDiscoveryResults = false}
class="mt-3 text-xs text-theme-muted hover:text-theme-primary"
>
Dismiss
</button>
</div>
{/if}
<!-- Active Backend Info -->
{#if backendsState.activeBackend}
<div class="rounded-lg border border-blue-900/50 bg-blue-900/20 p-4">
<div class="flex items-center gap-2 text-blue-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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-medium">Active: {getBackendDisplayName(backendsState.activeBackend.type)}</span>
{#if backendsState.activeBackend.version}
<span class="text-xs text-blue-300/70">v{backendsState.activeBackend.version}</span>
{/if}
</div>
<p class="mt-1 text-sm text-blue-300/70">{backendsState.activeBackend.baseUrl}</p>
<!-- Capabilities -->
<div class="mt-3 flex flex-wrap gap-2">
{#if backendsState.canPullModels}
<span class="rounded bg-green-900/30 px-2 py-1 text-xs text-green-400">Pull Models</span>
{/if}
{#if backendsState.canDeleteModels}
<span class="rounded bg-green-900/30 px-2 py-1 text-xs text-green-400">Delete Models</span>
{/if}
{#if backendsState.canCreateModels}
<span class="rounded bg-green-900/30 px-2 py-1 text-xs text-green-400">Create Custom</span>
{/if}
{#if backendsState.activeBackend.capabilities.canStreamChat}
<span class="rounded bg-blue-900/30 px-2 py-1 text-xs text-blue-400">Streaming</span>
{/if}
{#if backendsState.activeBackend.capabilities.canEmbed}
<span class="rounded bg-purple-900/30 px-2 py-1 text-xs text-purple-400">Embeddings</span>
{/if}
</div>
</div>
{:else if !backendsState.isLoading}
<div class="rounded-lg border border-amber-900/50 bg-amber-900/20 p-4">
<div class="flex items-center gap-2 text-amber-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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>No active backend configured. Click "Auto-Detect" to find available backends.</span>
</div>
</div>
{/if}
<!-- Backend Cards -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-theme-secondary">Available Backends</h3>
{#if backendsState.isLoading}
<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 gap-4">
<div class="h-10 w-10 rounded-lg bg-theme-tertiary"></div>
<div class="flex-1">
<div class="h-5 w-32 rounded bg-theme-tertiary"></div>
<div class="mt-2 h-4 w-48 rounded bg-theme-tertiary"></div>
</div>
</div>
</div>
{/each}
</div>
{:else if backendsState.backends.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="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>
<h3 class="mt-4 text-sm font-medium text-theme-muted">No backends configured</h3>
<p class="mt-1 text-sm text-theme-muted">
Click "Auto-Detect" to scan for available LLM backends
</p>
</div>
{:else}
{#each backendsState.backends as backend}
{@const isActive = backendsState.activeType === backend.type}
<div class="rounded-lg border transition-colors {isActive ? 'border-blue-500 bg-blue-900/10' : 'border-theme bg-theme-secondary hover:border-theme-subtle'}">
<div class="p-4">
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
<!-- Backend Icon -->
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-theme-tertiary">
{#if backend.type === 'ollama'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-theme-primary" 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-2" />
</svg>
{:else if backend.type === 'llamacpp'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{/if}
</div>
<div>
<div class="flex items-center gap-2">
<h4 class="font-medium text-theme-primary">{getBackendDisplayName(backend.type)}</h4>
<span class="flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs {backend.status === 'connected' ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'}">
<span class="h-1.5 w-1.5 rounded-full {getStatusColor(backend.status)}"></span>
{backend.status}
</span>
{#if isActive}
<span class="rounded bg-blue-600 px-2 py-0.5 text-xs font-medium text-white">Active</span>
{/if}
</div>
<p class="mt-1 text-sm text-theme-muted">{getBackendDescription(backend.type)}</p>
<p class="mt-1 text-xs text-theme-muted/70">{backend.baseUrl}</p>
</div>
</div>
<div class="flex items-center gap-2">
{#if !isActive && backend.status === 'connected'}
<button
type="button"
onclick={() => handleSetActive(backend.type)}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
Set Active
</button>
{/if}
</div>
</div>
{#if backend.error}
<div class="mt-3 rounded bg-red-900/20 px-3 py-2 text-xs text-red-400">
{backend.error}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<!-- Help Section -->
<div class="rounded-lg border border-theme bg-theme-secondary/50 p-4">
<h3 class="text-sm font-medium text-theme-secondary">Quick Start</h3>
<div class="mt-2 space-y-2 text-sm text-theme-muted">
<p><strong>Ollama:</strong> Run <code class="rounded bg-theme-tertiary px-1.5 py-0.5 text-xs">ollama serve</code> (default port 11434)</p>
<p><strong>llama.cpp:</strong> Run <code class="rounded bg-theme-tertiary px-1.5 py-0.5 text-xs">llama-server -m model.gguf</code> (default port 8081)</p>
<p><strong>LM Studio:</strong> Start local server from the app (default port 1234)</p>
</div>
</div>
</div>

View File

@@ -41,6 +41,7 @@
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}
aria-label="Toggle dark mode"
>
<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'}"
@@ -127,29 +128,4 @@
</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>

View File

@@ -14,6 +14,7 @@
} from '$lib/memory';
import type { StoredDocument } from '$lib/storage/db';
import { toastState, modelsState } from '$lib/stores';
import { ConfirmDialog } from '$lib/components/shared';
let documents = $state<StoredDocument[]>([]);
let stats = $state({ documentCount: 0, chunkCount: 0, totalTokens: 0 });
@@ -22,8 +23,9 @@
let uploadProgress = $state({ current: 0, total: 0 });
let selectedModel = $state(DEFAULT_EMBEDDING_MODEL);
let dragOver = $state(false);
let deleteConfirm = $state<{ show: boolean; doc: StoredDocument | null }>({ show: false, doc: null });
let fileInput: HTMLInputElement;
let fileInput = $state<HTMLInputElement | null>(null);
onMount(async () => {
await refreshData();
@@ -90,10 +92,14 @@
uploadProgress = { current: 0, total: 0 };
}
async function handleDelete(doc: StoredDocument) {
if (!confirm(`Delete "${doc.name}"? This cannot be undone.`)) {
return;
}
function handleDeleteClick(doc: StoredDocument) {
deleteConfirm = { show: true, doc };
}
async function confirmDelete() {
if (!deleteConfirm.doc) return;
const doc = deleteConfirm.doc;
deleteConfirm = { show: false, doc: null };
try {
await deleteDocument(doc.id);
@@ -232,7 +238,7 @@
<button
type="button"
onclick={() => handleDelete(doc)}
onclick={() => handleDeleteClick(doc)}
class="rounded p-2 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
aria-label="Delete document"
>
@@ -273,3 +279,13 @@
{/if}
</section>
</div>
<ConfirmDialog
isOpen={deleteConfirm.show}
title="Delete Document"
message={`Delete "${deleteConfirm.doc?.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={confirmDelete}
onCancel={() => (deleteConfirm = { show: false, doc: null })}
/>

View File

@@ -108,6 +108,7 @@
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}
aria-label="Toggle auto-compact"
>
<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'}"
@@ -192,6 +193,7 @@
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}
aria-label="Toggle custom model parameters"
>
<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'}"

View File

@@ -93,13 +93,12 @@
<!-- Enable custom parameters toggle -->
<div class="mb-4 flex items-center justify-between">
<label class="flex items-center gap-2 text-sm text-theme-secondary">
<span>Use custom parameters</span>
</label>
<span class="text-sm text-theme-secondary">Use custom parameters</span>
<button
type="button"
role="switch"
aria-checked={settingsState.useCustomParameters}
aria-label="Toggle custom model parameters"
onclick={() => settingsState.toggleCustomParameters(modelDefaults)}
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-theme-secondary {settingsState.useCustomParameters ? 'bg-sky-600' : 'bg-theme-tertiary'}"
>

View File

@@ -427,7 +427,7 @@
<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">
<button type="button" onclick={() => deleteError = null} class="ml-auto text-red-400 hover:text-red-300" aria-label="Dismiss error">
<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>
@@ -833,13 +833,13 @@
{#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">
<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" aria-label="Previous page">
<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">
<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" aria-label="Next page">
<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>
@@ -855,7 +855,7 @@
<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">
<button type="button" onclick={closeDetails} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary" aria-label="Close details">
<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>

View File

@@ -10,9 +10,11 @@
type PromptTemplate,
type PromptCategory
} from '$lib/prompts/templates';
import { ConfirmDialog } from '$lib/components/shared';
type Tab = 'my-prompts' | 'browse-templates';
let activeTab = $state<Tab>('my-prompts');
let deleteConfirm = $state<{ show: boolean; prompt: Prompt | null }>({ show: false, prompt: null });
let showEditor = $state(false);
let editingPrompt = $state<Prompt | null>(null);
@@ -106,10 +108,14 @@
}
}
async function handleDelete(prompt: Prompt): Promise<void> {
if (confirm(`Delete "${prompt.name}"? This cannot be undone.`)) {
await promptsState.remove(prompt.id);
}
function handleDeleteClick(prompt: Prompt): void {
deleteConfirm = { show: true, prompt };
}
async function confirmDelete(): Promise<void> {
if (!deleteConfirm.prompt) return;
await promptsState.remove(deleteConfirm.prompt.id);
deleteConfirm = { show: false, prompt: null };
}
async function handleSetDefault(prompt: Prompt): Promise<void> {
@@ -286,7 +292,7 @@
<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">
<button type="button" onclick={() => handleDeleteClick(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>
@@ -352,11 +358,11 @@
<!-- 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="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) closeEditor(); }} onkeydown={(e) => { if (e.key === 'Escape') closeEditor(); }} role="dialog" aria-modal="true" tabindex="-1">
<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">
<button type="button" onclick={closeEditor} aria-label="Close dialog" 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>
@@ -386,8 +392,8 @@
<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>
<fieldset>
<legend class="mb-2 block text-sm font-medium text-theme-secondary">Auto-use for model types</legend>
<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}>
@@ -395,7 +401,7 @@
</button>
{/each}
</div>
</div>
</fieldset>
</div>
<div class="mt-6 flex justify-end gap-3">
@@ -412,7 +418,7 @@
<!-- 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="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) previewTemplate = null; }} onkeydown={(e) => { if (e.key === 'Escape') previewTemplate = null; }} role="dialog" aria-modal="true" tabindex="-1">
<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>
@@ -422,7 +428,7 @@
{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">
<button type="button" onclick={() => (previewTemplate = null)} aria-label="Close dialog" 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>
@@ -444,3 +450,13 @@
</div>
</div>
{/if}
<ConfirmDialog
isOpen={deleteConfirm.show}
title="Delete Prompt"
message={`Delete "${deleteConfirm.prompt?.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={confirmDelete}
onCancel={() => (deleteConfirm = { show: false, prompt: null })}
/>

View File

@@ -2,7 +2,7 @@
/**
* SettingsTabs - Horizontal tab navigation for Settings Hub
*/
export type SettingsTab = 'general' | 'models' | 'prompts' | 'tools' | 'knowledge' | 'memory';
export type SettingsTab = 'general' | 'ai' | 'prompts' | 'tools' | 'agents' | 'knowledge' | 'memory' | 'about';
</script>
<script lang="ts">
@@ -16,11 +16,13 @@
const tabs: Tab[] = [
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'models', label: 'Models', icon: 'cpu' },
{ id: 'ai', label: 'AI Providers', icon: 'server' },
{ id: 'prompts', label: 'Prompts', icon: 'message' },
{ id: 'tools', label: 'Tools', icon: 'wrench' },
{ id: 'agents', label: 'Agents', icon: 'robot' },
{ id: 'knowledge', label: 'Knowledge', icon: 'book' },
{ id: 'memory', label: 'Memory', icon: 'brain' }
{ id: 'memory', label: 'Memory', icon: 'brain' },
{ id: 'about', label: 'About', icon: 'info' }
];
// Get active tab from URL, default to 'general'
@@ -43,7 +45,11 @@
<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'}
{:else if tab.icon === 'server'}
<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 0 1-2-2V6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2M5 12a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2m-2-4h.01M17 16h.01" />
</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>
@@ -59,10 +65,18 @@
<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 === 'robot'}
<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.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</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>
{:else if tab.icon === 'info'}
<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.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
{/if}
{tab.label}
</a>

View File

@@ -5,11 +5,13 @@
import { toolsState } from '$lib/stores';
import type { ToolDefinition, CustomTool } from '$lib/tools';
import { ToolEditor } from '$lib/components/tools';
import { ConfirmDialog } from '$lib/components/shared';
let showEditor = $state(false);
let editingTool = $state<CustomTool | null>(null);
let searchQuery = $state('');
let expandedDescriptions = $state<Set<string>>(new Set());
let deleteConfirm = $state<{ show: boolean; tool: CustomTool | null }>({ show: false, tool: null });
function openCreateEditor(): void {
editingTool = null;
@@ -32,9 +34,14 @@
}
function handleDeleteTool(tool: CustomTool): void {
if (confirm(`Delete "${tool.name}"? This cannot be undone.`)) {
toolsState.removeCustomTool(tool.id);
deleteConfirm = { show: true, tool };
}
function confirmDeleteTool(): void {
if (deleteConfirm.tool) {
toolsState.removeCustomTool(deleteConfirm.tool.id);
}
deleteConfirm = { show: false, tool: null };
}
const allTools = $derived(toolsState.getAllToolsWithState());
@@ -144,6 +151,7 @@
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}
aria-label="Toggle all tools"
>
<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>
@@ -187,6 +195,7 @@
type="button"
onclick={() => searchQuery = ''}
class="absolute right-3 top-1/2 -translate-y-1/2 text-theme-muted hover:text-theme-primary"
aria-label="Clear search"
>
<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" />
@@ -282,6 +291,7 @@
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}
aria-label="Toggle {tool.definition.function.name} tool"
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>
@@ -431,6 +441,7 @@
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}
aria-label="Toggle {tool.name} tool"
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>
@@ -509,3 +520,13 @@
onClose={() => { showEditor = false; editingTool = null; }}
onSave={handleSaveTool}
/>
<ConfirmDialog
isOpen={deleteConfirm.show}
title="Delete Tool"
message={`Delete "${deleteConfirm.tool?.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={confirmDeleteTool}
onCancel={() => (deleteConfirm = { show: false, tool: null })}
/>

View File

@@ -3,11 +3,13 @@
*/
export { default as SettingsTabs } from './SettingsTabs.svelte';
export { default as GeneralTab } from './GeneralTab.svelte';
export { default as ModelsTab } from './ModelsTab.svelte';
export { default as AIProvidersTab } from './AIProvidersTab.svelte';
export { default as PromptsTab } from './PromptsTab.svelte';
export { default as ToolsTab } from './ToolsTab.svelte';
export { default as AgentsTab } from './AgentsTab.svelte';
export { default as KnowledgeTab } from './KnowledgeTab.svelte';
export { default as MemoryTab } from './MemoryTab.svelte';
export { default as AboutTab } from './AboutTab.svelte';
export { default as ModelParametersPanel } from './ModelParametersPanel.svelte';
export type { SettingsTab } from './SettingsTabs.svelte';

View File

@@ -0,0 +1,156 @@
/**
* ConfirmDialog component tests
*
* Tests the confirmation dialog component
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
describe('ConfirmDialog', () => {
const defaultProps = {
isOpen: true,
title: 'Confirm Action',
message: 'Are you sure you want to proceed?',
onConfirm: vi.fn(),
onCancel: vi.fn()
};
it('does not render when closed', () => {
render(ConfirmDialog, {
props: {
...defaultProps,
isOpen: false
}
});
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders when open', () => {
render(ConfirmDialog, { props: defaultProps });
const dialog = screen.getByRole('dialog');
expect(dialog).toBeDefined();
expect(dialog.getAttribute('aria-modal')).toBe('true');
});
it('displays title and message', () => {
render(ConfirmDialog, { props: defaultProps });
expect(screen.getByText('Confirm Action')).toBeDefined();
expect(screen.getByText('Are you sure you want to proceed?')).toBeDefined();
});
it('uses default button text', () => {
render(ConfirmDialog, { props: defaultProps });
expect(screen.getByText('Confirm')).toBeDefined();
expect(screen.getByText('Cancel')).toBeDefined();
});
it('uses custom button text', () => {
render(ConfirmDialog, {
props: {
...defaultProps,
confirmText: 'Delete',
cancelText: 'Keep'
}
});
expect(screen.getByText('Delete')).toBeDefined();
expect(screen.getByText('Keep')).toBeDefined();
});
it('calls onConfirm when confirm button clicked', async () => {
const onConfirm = vi.fn();
render(ConfirmDialog, {
props: {
...defaultProps,
onConfirm
}
});
const confirmButton = screen.getByText('Confirm');
await fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledOnce();
});
it('calls onCancel when cancel button clicked', async () => {
const onCancel = vi.fn();
render(ConfirmDialog, {
props: {
...defaultProps,
onCancel
}
});
const cancelButton = screen.getByText('Cancel');
await fireEvent.click(cancelButton);
expect(onCancel).toHaveBeenCalledOnce();
});
it('calls onCancel when Escape key pressed', async () => {
const onCancel = vi.fn();
render(ConfirmDialog, {
props: {
...defaultProps,
onCancel
}
});
const dialog = screen.getByRole('dialog');
await fireEvent.keyDown(dialog, { key: 'Escape' });
expect(onCancel).toHaveBeenCalledOnce();
});
it('has proper aria attributes', () => {
render(ConfirmDialog, { props: defaultProps });
const dialog = screen.getByRole('dialog');
expect(dialog.getAttribute('aria-labelledby')).toBe('confirm-dialog-title');
expect(dialog.getAttribute('aria-describedby')).toBe('confirm-dialog-description');
});
describe('variants', () => {
it('renders danger variant with red styling', () => {
render(ConfirmDialog, {
props: {
...defaultProps,
variant: 'danger'
}
});
const confirmButton = screen.getByText('Confirm');
expect(confirmButton.className).toContain('bg-red-600');
});
it('renders warning variant with amber styling', () => {
render(ConfirmDialog, {
props: {
...defaultProps,
variant: 'warning'
}
});
const confirmButton = screen.getByText('Confirm');
expect(confirmButton.className).toContain('bg-amber-600');
});
it('renders info variant with emerald styling', () => {
render(ConfirmDialog, {
props: {
...defaultProps,
variant: 'info'
}
});
const confirmButton = screen.getByText('Confirm');
expect(confirmButton.className).toContain('bg-emerald-600');
});
});
});

View File

@@ -20,7 +20,7 @@
let { isOpen, onClose }: Props = $props();
let fileInput: HTMLInputElement;
let fileInput = $state<HTMLInputElement | null>(null);
let isDragOver = $state(false);
let selectedFile = $state<File | null>(null);
let validationResult = $state<ValidationResult | null>(null);
@@ -168,9 +168,11 @@
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="import-dialog-title"
tabindex="-1"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-lg rounded-xl border border-theme bg-theme-primary shadow-2xl">

View File

@@ -163,9 +163,11 @@
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/60 pt-[15vh] backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="search-dialog-title"
tabindex="-1"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-2xl rounded-xl border border-theme bg-theme-primary shadow-2xl">

View File

@@ -61,9 +61,11 @@
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-dialog-title"
tabindex="-1"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-md rounded-xl border border-theme bg-theme-primary shadow-2xl">

View File

@@ -0,0 +1,67 @@
/**
* Skeleton component tests
*
* Tests the loading placeholder component
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Skeleton from './Skeleton.svelte';
describe('Skeleton', () => {
it('renders with default props', () => {
render(Skeleton);
const skeleton = screen.getByRole('status');
expect(skeleton).toBeDefined();
expect(skeleton.getAttribute('aria-label')).toBe('Loading...');
});
it('renders with custom width and height', () => {
render(Skeleton, { props: { width: '200px', height: '50px' } });
const skeleton = screen.getByRole('status');
expect(skeleton.style.width).toBe('200px');
expect(skeleton.style.height).toBe('50px');
});
it('renders circular variant', () => {
render(Skeleton, { props: { variant: 'circular' } });
const skeleton = screen.getByRole('status');
expect(skeleton.className).toContain('rounded-full');
});
it('renders rectangular variant', () => {
render(Skeleton, { props: { variant: 'rectangular' } });
const skeleton = screen.getByRole('status');
expect(skeleton.className).toContain('rounded-none');
});
it('renders rounded variant', () => {
render(Skeleton, { props: { variant: 'rounded' } });
const skeleton = screen.getByRole('status');
expect(skeleton.className).toContain('rounded-lg');
});
it('renders text variant by default', () => {
render(Skeleton, { props: { variant: 'text' } });
const skeleton = screen.getByRole('status');
expect(skeleton.className).toContain('rounded');
});
it('renders multiple lines for text variant', () => {
render(Skeleton, { props: { variant: 'text', lines: 3 } });
const skeletons = screen.getAllByRole('status');
expect(skeletons).toHaveLength(3);
});
it('applies custom class', () => {
render(Skeleton, { props: { class: 'my-custom-class' } });
const skeleton = screen.getByRole('status');
expect(skeleton.className).toContain('my-custom-class');
});
it('has animate-pulse class for loading effect', () => {
render(Skeleton);
const skeleton = screen.getByRole('status');
expect(skeleton.className).toContain('animate-pulse');
});
});

View File

@@ -0,0 +1,154 @@
<script lang="ts">
/**
* SyncWarningBanner.svelte - Warning banner for sync failures
* Shows when backend is disconnected for >30 seconds continuously
*/
import { syncState } from '$lib/backend';
import { onMount } from 'svelte';
/** Threshold before showing banner (30 seconds) */
const FAILURE_THRESHOLD_MS = 30_000;
/** Track when failure started */
let failureStartTime = $state<number | null>(null);
/** Whether banner has been dismissed for this failure period */
let isDismissed = $state(false);
/** Whether enough time has passed to show banner */
let thresholdReached = $state(false);
/** Interval for checking threshold */
let checkInterval: ReturnType<typeof setInterval> | null = null;
/** Check if we're in a failure state */
let isInFailureState = $derived(
syncState.status === 'error' || syncState.status === 'offline' || !syncState.isOnline
);
/** Should show the banner */
let shouldShow = $derived(isInFailureState && thresholdReached && !isDismissed);
/** Watch for failure state changes */
$effect(() => {
if (isInFailureState) {
// Start tracking failure time if not already
if (failureStartTime === null) {
failureStartTime = Date.now();
isDismissed = false;
thresholdReached = false;
// Start interval to check threshold
if (checkInterval) clearInterval(checkInterval);
checkInterval = setInterval(() => {
if (failureStartTime && Date.now() - failureStartTime >= FAILURE_THRESHOLD_MS) {
thresholdReached = true;
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
}
}, 1000);
}
} else {
// Reset on recovery
failureStartTime = null;
isDismissed = false;
thresholdReached = false;
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
}
});
onMount(() => {
return () => {
if (checkInterval) {
clearInterval(checkInterval);
}
};
});
/** Dismiss the banner */
function handleDismiss() {
isDismissed = true;
}
</script>
{#if shouldShow}
<div
class="fixed left-0 right-0 top-12 z-50 flex items-center justify-center px-4 animate-in"
role="alert"
>
<div
class="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-red-400 shadow-lg backdrop-blur-sm"
>
<!-- Warning icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
<!-- Message -->
<span class="text-sm font-medium">
Backend not connected. Your data is only stored in this browser.
</span>
<!-- Pending count if any -->
{#if syncState.pendingCount > 0}
<span
class="rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium"
>
{syncState.pendingCount} pending
</span>
{/if}
<!-- Dismiss button -->
<button
type="button"
onclick={handleDismiss}
class="ml-1 flex-shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100"
aria-label="Dismiss sync warning"
>
<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>
</div>
{/if}
<style>
@keyframes slide-in-from-top {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-in {
animation: slide-in-from-top 0.3s ease-out;
}
</style>

View File

@@ -248,6 +248,7 @@ print(json.dumps(result))`;
type="button"
onclick={onClose}
class="rounded-lg p-1.5 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Close dialog"
>
<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" />
@@ -290,7 +291,7 @@ print(json.dumps(result))`;
<!-- Parameters -->
<div>
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-theme-secondary">Parameters</label>
<span class="block text-sm font-medium text-theme-secondary">Parameters</span>
<button
type="button"
onclick={addParameter}
@@ -335,6 +336,7 @@ print(json.dumps(result))`;
type="button"
onclick={() => removeParameter(index)}
class="text-theme-muted hover:text-red-400"
aria-label="Remove parameter"
>
<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="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
@@ -352,8 +354,8 @@ print(json.dumps(result))`;
</div>
<!-- Implementation Type -->
<div>
<label class="block text-sm font-medium text-theme-secondary">Implementation</label>
<fieldset>
<legend class="block text-sm font-medium text-theme-secondary">Implementation</legend>
<div class="mt-2 flex flex-wrap gap-4">
<label class="flex items-center gap-2 text-theme-secondary">
<input
@@ -383,15 +385,15 @@ print(json.dumps(result))`;
HTTP Endpoint
</label>
</div>
</div>
</fieldset>
<!-- Code Editor (JavaScript or Python) -->
{#if implementation === 'javascript' || implementation === 'python'}
<div>
<div class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-theme-secondary">
<span class="block text-sm font-medium text-theme-secondary">
{implementation === 'javascript' ? 'JavaScript' : 'Python'} Code
</label>
</span>
<div class="flex items-center gap-2">
<!-- Templates dropdown -->
<div class="relative">
@@ -500,8 +502,8 @@ print(json.dumps(result))`;
<p class="mt-1 text-sm text-red-400">{errors.endpoint}</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium text-theme-secondary">HTTP Method</label>
<fieldset>
<legend class="block text-sm font-medium text-theme-secondary">HTTP Method</legend>
<div class="mt-2 flex gap-4">
<label class="flex items-center gap-2 text-theme-secondary">
<input type="radio" bind:group={httpMethod} value="GET" />
@@ -512,7 +514,7 @@ print(json.dumps(result))`;
POST
</label>
</div>
</div>
</fieldset>
<!-- Test button for HTTP -->
<button
@@ -548,6 +550,7 @@ print(json.dumps(result))`;
class="relative inline-flex h-6 w-11 cursor-pointer rounded-full transition-colors {enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={enabled}
aria-label="Enable tool"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition {enabled ? 'translate-x-5' : 'translate-x-0'}"

View File

@@ -209,7 +209,7 @@
<div class="space-y-4">
<!-- Input -->
<div>
<label class="block text-xs font-medium text-theme-secondary mb-1">Input Arguments (JSON)</label>
<span class="block text-xs font-medium text-theme-secondary mb-1">Input Arguments (JSON)</span>
<CodeEditor bind:value={testInput} language="json" minHeight="80px" />
</div>
@@ -237,7 +237,7 @@
<!-- Result -->
{#if testResult}
<div>
<label class="block text-xs font-medium text-theme-secondary mb-1">Result</label>
<span class="block text-xs font-medium text-theme-secondary mb-1">Result</span>
<div
class="rounded-lg p-3 text-sm font-mono overflow-x-auto {testResult.success
? 'bg-emerald-900/30 border border-emerald-500/30'

View File

@@ -0,0 +1,225 @@
/**
* Tests for Unified LLM Client
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Types matching the backend response
interface ChatChunk {
model: string;
message?: {
role: string;
content: string;
};
done: boolean;
done_reason?: string;
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
eval_count?: number;
}
interface Model {
name: string;
size: number;
digest: string;
modified_at: string;
}
describe('UnifiedLLMClient', () => {
let UnifiedLLMClient: typeof import('./client.js').UnifiedLLMClient;
let client: InstanceType<typeof UnifiedLLMClient>;
beforeEach(async () => {
vi.resetModules();
// Mock fetch
global.fetch = vi.fn();
const module = await import('./client.js');
UnifiedLLMClient = module.UnifiedLLMClient;
client = new UnifiedLLMClient();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('listModels', () => {
it('fetches models from unified API', async () => {
const mockModels: Model[] = [
{
name: 'llama3.2:8b',
size: 4500000000,
digest: 'abc123',
modified_at: '2024-01-15T10:00:00Z'
}
];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ models: mockModels, backend: 'ollama' })
});
const result = await client.listModels();
expect(result.models).toEqual(mockModels);
expect(result.backend).toBe('ollama');
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/ai/models'),
expect.objectContaining({ method: 'GET' })
);
});
it('throws on API error', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
json: async () => ({ error: 'no active backend' })
});
await expect(client.listModels()).rejects.toThrow('no active backend');
});
});
describe('chat', () => {
it('sends chat request to unified API', async () => {
const mockResponse: ChatChunk = {
model: 'llama3.2:8b',
message: { role: 'assistant', content: 'Hello!' },
done: true,
total_duration: 1000000000,
eval_count: 10
};
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse
});
const result = await client.chat({
model: 'llama3.2:8b',
messages: [{ role: 'user', content: 'Hi' }]
});
expect(result.message?.content).toBe('Hello!');
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/ai/chat'),
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"model":"llama3.2:8b"')
})
);
});
});
describe('streamChat', () => {
it('streams chat responses as NDJSON', async () => {
const chunks: ChatChunk[] = [
{ model: 'llama3.2:8b', message: { role: 'assistant', content: 'Hello' }, done: false },
{ model: 'llama3.2:8b', message: { role: 'assistant', content: ' there' }, done: false },
{ model: 'llama3.2:8b', message: { role: 'assistant', content: '!' }, done: true }
];
// Create a mock readable stream
const mockBody = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(JSON.stringify(chunk) + '\n'));
}
controller.close();
}
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
body: mockBody
});
const receivedChunks: ChatChunk[] = [];
for await (const chunk of client.streamChat({
model: 'llama3.2:8b',
messages: [{ role: 'user', content: 'Hi' }]
})) {
receivedChunks.push(chunk);
}
expect(receivedChunks).toHaveLength(3);
expect(receivedChunks[0].message?.content).toBe('Hello');
expect(receivedChunks[2].done).toBe(true);
});
it('handles stream errors', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({ error: 'Internal Server Error' })
});
const generator = client.streamChat({
model: 'llama3.2:8b',
messages: [{ role: 'user', content: 'Hi' }]
});
await expect(generator.next()).rejects.toThrow('Internal Server Error');
});
});
describe('healthCheck', () => {
it('returns true when backend is healthy', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ status: 'healthy' })
});
const result = await client.healthCheck('ollama');
expect(result).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/ai/backends/ollama/health'),
expect.any(Object)
);
});
it('returns false when backend is unhealthy', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({ status: 'unhealthy', error: 'Connection refused' })
});
const result = await client.healthCheck('ollama');
expect(result).toBe(false);
});
});
describe('configuration', () => {
it('uses custom base URL', async () => {
const customClient = new UnifiedLLMClient({ baseUrl: 'http://custom:9090' });
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ models: [], backend: 'ollama' })
});
await customClient.listModels();
expect(global.fetch).toHaveBeenCalledWith(
'http://custom:9090/api/v1/ai/models',
expect.any(Object)
);
});
it('respects abort signal', async () => {
const controller = new AbortController();
controller.abort();
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new DOMException('The user aborted a request.', 'AbortError')
);
await expect(client.listModels(controller.signal)).rejects.toThrow('aborted');
});
});
});

View File

@@ -0,0 +1,340 @@
/**
* Unified LLM Client
* Routes chat requests through the unified /api/v1/ai/* endpoints
* Supports Ollama, llama.cpp, and LM Studio backends transparently
*/
import type { BackendType } from '../stores/backends.svelte.js';
/** Message format (compatible with Ollama and OpenAI) */
export interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
images?: string[];
tool_calls?: ToolCall[];
}
/** Tool call in assistant message */
export interface ToolCall {
function: {
name: string;
arguments: Record<string, unknown>;
};
}
/** Tool definition */
export interface ToolDefinition {
type: 'function';
function: {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
};
};
}
/** Chat request options */
export interface ChatRequest {
model: string;
messages: ChatMessage[];
stream?: boolean;
format?: 'json' | object;
tools?: ToolDefinition[];
options?: ModelOptions;
keep_alive?: string;
}
/** Model-specific options */
export interface ModelOptions {
temperature?: number;
top_p?: number;
top_k?: number;
num_ctx?: number;
num_predict?: number;
stop?: string[];
seed?: number;
}
/** Chat response chunk (NDJSON streaming format) */
export interface ChatChunk {
model: string;
message?: ChatMessage;
done: boolean;
done_reason?: string;
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
prompt_eval_duration?: number;
eval_count?: number;
eval_duration?: number;
error?: string;
}
/** Model information */
export interface Model {
name: string;
size: number;
digest: string;
modified_at: string;
details?: {
family?: string;
parameter_size?: string;
quantization_level?: string;
};
}
/** Models list response */
export interface ModelsResponse {
models: Model[];
backend: string;
}
/** Client configuration */
export interface UnifiedLLMClientConfig {
baseUrl?: string;
defaultTimeoutMs?: number;
fetchFn?: typeof fetch;
}
const DEFAULT_CONFIG = {
baseUrl: '',
defaultTimeoutMs: 120000
};
/**
* Unified LLM client that routes requests through the multi-backend API
*/
export class UnifiedLLMClient {
private readonly config: Required<Omit<UnifiedLLMClientConfig, 'fetchFn'>>;
private readonly fetchFn: typeof fetch;
constructor(config: UnifiedLLMClientConfig = {}) {
this.config = {
...DEFAULT_CONFIG,
...config
};
this.fetchFn = config.fetchFn ?? fetch;
}
/**
* Lists models from the active backend
*/
async listModels(signal?: AbortSignal): Promise<ModelsResponse> {
return this.request<ModelsResponse>('/api/v1/ai/models', {
method: 'GET',
signal
});
}
/**
* Non-streaming chat completion
*/
async chat(request: ChatRequest, signal?: AbortSignal): Promise<ChatChunk> {
return this.request<ChatChunk>('/api/v1/ai/chat', {
method: 'POST',
body: JSON.stringify({ ...request, stream: false }),
signal
});
}
/**
* Streaming chat completion (async generator)
* Yields NDJSON chunks as they arrive
*/
async *streamChat(
request: ChatRequest,
signal?: AbortSignal
): AsyncGenerator<ChatChunk, void, unknown> {
const url = `${this.config.baseUrl}/api/v1/ai/chat`;
const response = await this.fetchFn(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...request, stream: true }),
signal
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('No response body for streaming');
}
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 NDJSON 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 chunk = JSON.parse(line) as ChatChunk;
// Check for error in chunk
if (chunk.error) {
throw new Error(chunk.error);
}
yield chunk;
// Stop if done
if (chunk.done) {
return;
}
} catch (e) {
if (e instanceof SyntaxError) {
console.warn('[UnifiedLLM] Failed to parse chunk:', line);
} else {
throw e;
}
}
}
}
} finally {
reader.releaseLock();
}
}
/**
* Streaming chat with callbacks (more ergonomic for UI)
*/
async streamChatWithCallbacks(
request: ChatRequest,
callbacks: {
onChunk?: (chunk: ChatChunk) => void;
onToken?: (token: string) => void;
onComplete?: (fullResponse: ChatChunk) => void;
onError?: (error: Error) => void;
},
signal?: AbortSignal
): Promise<string> {
let accumulatedContent = '';
let lastChunk: ChatChunk | null = null;
try {
for await (const chunk of this.streamChat(request, signal)) {
lastChunk = chunk;
callbacks.onChunk?.(chunk);
if (chunk.message?.content) {
accumulatedContent += chunk.message.content;
callbacks.onToken?.(chunk.message.content);
}
if (chunk.done && callbacks.onComplete) {
callbacks.onComplete(chunk);
}
}
} catch (error) {
if (callbacks.onError && error instanceof Error) {
callbacks.onError(error);
}
throw error;
}
return accumulatedContent;
}
/**
* Check health of a specific backend
*/
async healthCheck(type: BackendType, signal?: AbortSignal): Promise<boolean> {
try {
await this.request<{ status: string }>(`/api/v1/ai/backends/${type}/health`, {
method: 'GET',
signal,
timeoutMs: 5000
});
return true;
} catch {
return false;
}
}
/**
* Make an HTTP request to the unified API
*/
private async request<T>(
endpoint: string,
options: {
method: 'GET' | 'POST';
body?: string;
signal?: AbortSignal;
timeoutMs?: number;
}
): Promise<T> {
const { method, body, signal, timeoutMs = this.config.defaultTimeoutMs } = options;
const url = `${this.config.baseUrl}${endpoint}`;
// Create timeout controller
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
// Combine with external signal
const combinedSignal = signal ? this.combineSignals(signal, controller.signal) : controller.signal;
try {
const response = await this.fetchFn(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body,
signal: combinedSignal
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
return (await response.json()) as T;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* Combines multiple AbortSignals into one
*/
private combineSignals(...signals: AbortSignal[]): AbortSignal {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
break;
}
signal.addEventListener('abort', () => controller.abort(signal.reason), {
once: true,
signal: controller.signal
});
}
return controller.signal;
}
}
/** Default client instance */
export const unifiedLLMClient = new UnifiedLLMClient();

View File

@@ -0,0 +1,15 @@
/**
* Unified LLM Client exports
*/
export { UnifiedLLMClient, unifiedLLMClient } from './client.js';
export type {
ChatMessage,
ChatRequest,
ChatChunk,
Model,
ModelsResponse,
ModelOptions,
ToolCall,
ToolDefinition,
UnifiedLLMClientConfig
} from './client.js';

View File

@@ -0,0 +1,243 @@
/**
* Chunker tests
*
* Tests the text chunking utilities for RAG
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
chunkText,
splitByParagraphs,
splitBySentences,
estimateChunkTokens,
mergeSmallChunks
} from './chunker';
import type { DocumentChunk } from './types';
// Mock crypto.randomUUID for deterministic tests
let uuidCounter = 0;
beforeEach(() => {
uuidCounter = 0;
vi.spyOn(crypto, 'randomUUID').mockImplementation(() => `00000000-0000-0000-0000-00000000000${++uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('splitByParagraphs', () => {
it('splits text by double newlines', () => {
const text = 'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.';
const result = splitByParagraphs(text);
expect(result).toEqual([
'First paragraph.',
'Second paragraph.',
'Third paragraph.'
]);
});
it('handles extra whitespace between paragraphs', () => {
const text = 'First.\n\n\n\nSecond.\n \n \nThird.';
const result = splitByParagraphs(text);
expect(result).toEqual(['First.', 'Second.', 'Third.']);
});
it('returns empty array for empty input', () => {
expect(splitByParagraphs('')).toEqual([]);
expect(splitByParagraphs(' ')).toEqual([]);
});
it('returns single element for text without paragraph breaks', () => {
const text = 'Single paragraph with no breaks.';
const result = splitByParagraphs(text);
expect(result).toEqual(['Single paragraph with no breaks.']);
});
});
describe('splitBySentences', () => {
it('splits by periods', () => {
const text = 'First sentence. Second sentence. Third sentence.';
const result = splitBySentences(text);
expect(result).toEqual([
'First sentence.',
'Second sentence.',
'Third sentence.'
]);
});
it('splits by exclamation marks', () => {
const text = 'Wow! That is amazing! Really!';
const result = splitBySentences(text);
expect(result).toEqual(['Wow!', 'That is amazing!', 'Really!']);
});
it('splits by question marks', () => {
const text = 'Is this working? Are you sure? Yes.';
const result = splitBySentences(text);
expect(result).toEqual(['Is this working?', 'Are you sure?', 'Yes.']);
});
it('handles mixed punctuation', () => {
const text = 'Hello. How are you? Great! Thanks.';
const result = splitBySentences(text);
expect(result).toEqual(['Hello.', 'How are you?', 'Great!', 'Thanks.']);
});
it('returns empty array for empty input', () => {
expect(splitBySentences('')).toEqual([]);
});
});
describe('estimateChunkTokens', () => {
it('estimates roughly 4 characters per token', () => {
// 100 characters should be ~25 tokens
const text = 'a'.repeat(100);
expect(estimateChunkTokens(text)).toBe(25);
});
it('rounds up for partial tokens', () => {
// 10 characters = 2.5 tokens, rounds to 3
const text = 'a'.repeat(10);
expect(estimateChunkTokens(text)).toBe(3);
});
it('returns 0 for empty string', () => {
expect(estimateChunkTokens('')).toBe(0);
});
});
describe('chunkText', () => {
const DOC_ID = 'test-doc';
it('returns empty array for empty text', () => {
expect(chunkText('', DOC_ID)).toEqual([]);
});
it('returns single chunk for short text', () => {
const text = 'Short text that fits in one chunk.';
const result = chunkText(text, DOC_ID, { chunkSize: 512 });
expect(result).toHaveLength(1);
expect(result[0].content).toBe(text);
expect(result[0].documentId).toBe(DOC_ID);
expect(result[0].startIndex).toBe(0);
expect(result[0].endIndex).toBe(text.length);
});
it('splits long text into multiple chunks', () => {
// Create text longer than chunk size
const text = 'This is sentence one. '.repeat(50);
const result = chunkText(text, DOC_ID, { chunkSize: 200, overlap: 20 });
expect(result.length).toBeGreaterThan(1);
// Each chunk should be roughly chunk size (allowing for break points)
for (const chunk of result) {
expect(chunk.content.length).toBeLessThanOrEqual(250); // Some flexibility for break points
expect(chunk.documentId).toBe(DOC_ID);
}
});
it('respects sentence boundaries when enabled', () => {
const text = 'First sentence here. Second sentence here. Third sentence here. Fourth sentence here.';
const result = chunkText(text, DOC_ID, {
chunkSize: 50,
overlap: 10,
respectSentences: true
});
// Chunks should not split mid-sentence
for (const chunk of result) {
// Each chunk should end with punctuation or be the last chunk
const endsWithPunctuation = /[.!?]$/.test(chunk.content);
const isLastChunk = chunk === result[result.length - 1];
expect(endsWithPunctuation || isLastChunk).toBe(true);
}
});
it('creates chunks with correct indices', () => {
const text = 'A'.repeat(100) + ' ' + 'B'.repeat(100);
const result = chunkText(text, DOC_ID, { chunkSize: 100, overlap: 10 });
// Verify indices are valid
for (const chunk of result) {
expect(chunk.startIndex).toBeGreaterThanOrEqual(0);
expect(chunk.endIndex).toBeLessThanOrEqual(text.length);
expect(chunk.startIndex).toBeLessThan(chunk.endIndex);
}
});
it('generates unique IDs for each chunk', () => {
const text = 'Sentence one. Sentence two. Sentence three. Sentence four. Sentence five.';
const result = chunkText(text, DOC_ID, { chunkSize: 30, overlap: 5 });
const ids = result.map(c => c.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
});
describe('mergeSmallChunks', () => {
function makeChunk(content: string, startIndex: number = 0): DocumentChunk {
return {
id: `chunk-${content.slice(0, 10)}`,
documentId: 'doc-1',
content,
startIndex,
endIndex: startIndex + content.length
};
}
it('returns empty array for empty input', () => {
expect(mergeSmallChunks([])).toEqual([]);
});
it('returns single chunk unchanged', () => {
const chunks = [makeChunk('Single chunk content.')];
const result = mergeSmallChunks(chunks);
expect(result).toHaveLength(1);
expect(result[0].content).toBe('Single chunk content.');
});
it('merges adjacent small chunks', () => {
const chunks = [
makeChunk('Small.', 0),
makeChunk('Also small.', 10)
];
const result = mergeSmallChunks(chunks, 200);
expect(result).toHaveLength(1);
expect(result[0].content).toBe('Small.\n\nAlso small.');
});
it('does not merge chunks that exceed minSize together', () => {
const chunks = [
makeChunk('A'.repeat(100), 0),
makeChunk('B'.repeat(100), 100)
];
const result = mergeSmallChunks(chunks, 150);
expect(result).toHaveLength(2);
});
it('preserves startIndex from first chunk and endIndex from last when merging', () => {
const chunks = [
makeChunk('First chunk.', 0),
makeChunk('Second chunk.', 15)
];
const result = mergeSmallChunks(chunks, 200);
expect(result).toHaveLength(1);
expect(result[0].startIndex).toBe(0);
expect(result[0].endIndex).toBe(15 + 'Second chunk.'.length);
});
});

View File

@@ -0,0 +1,194 @@
/**
* Embeddings utility tests
*
* Tests the pure vector math functions
*/
import { describe, it, expect } from 'vitest';
import {
cosineSimilarity,
findSimilar,
normalizeVector,
getEmbeddingDimension
} from './embeddings';
describe('cosineSimilarity', () => {
it('returns 1 for identical vectors', () => {
const v = [1, 2, 3];
expect(cosineSimilarity(v, v)).toBeCloseTo(1, 10);
});
it('returns -1 for opposite vectors', () => {
const a = [1, 2, 3];
const b = [-1, -2, -3];
expect(cosineSimilarity(a, b)).toBeCloseTo(-1, 10);
});
it('returns 0 for orthogonal vectors', () => {
const a = [1, 0];
const b = [0, 1];
expect(cosineSimilarity(a, b)).toBeCloseTo(0, 10);
});
it('handles normalized vectors', () => {
const a = [0.6, 0.8];
const b = [0.8, 0.6];
const sim = cosineSimilarity(a, b);
expect(sim).toBeGreaterThan(0);
expect(sim).toBeLessThan(1);
expect(sim).toBeCloseTo(0.96, 2);
});
it('throws for mismatched dimensions', () => {
const a = [1, 2, 3];
const b = [1, 2];
expect(() => cosineSimilarity(a, b)).toThrow("Vector dimensions don't match");
});
it('returns 0 for zero vectors', () => {
const a = [0, 0, 0];
const b = [1, 2, 3];
expect(cosineSimilarity(a, b)).toBe(0);
});
it('handles large vectors', () => {
const size = 768;
const a = Array(size)
.fill(0)
.map(() => Math.random());
const b = Array(size)
.fill(0)
.map(() => Math.random());
const sim = cosineSimilarity(a, b);
expect(sim).toBeGreaterThanOrEqual(-1);
expect(sim).toBeLessThanOrEqual(1);
});
});
describe('normalizeVector', () => {
it('converts to unit vector', () => {
const v = [3, 4];
const normalized = normalizeVector(v);
// Check it's a unit vector
const magnitude = Math.sqrt(normalized.reduce((sum, x) => sum + x * x, 0));
expect(magnitude).toBeCloseTo(1, 10);
});
it('preserves direction', () => {
const v = [3, 4];
const normalized = normalizeVector(v);
expect(normalized[0]).toBeCloseTo(0.6, 10);
expect(normalized[1]).toBeCloseTo(0.8, 10);
});
it('handles zero vector', () => {
const v = [0, 0, 0];
const normalized = normalizeVector(v);
expect(normalized).toEqual([0, 0, 0]);
});
it('handles already-normalized vector', () => {
const v = [0.6, 0.8];
const normalized = normalizeVector(v);
expect(normalized[0]).toBeCloseTo(0.6, 10);
expect(normalized[1]).toBeCloseTo(0.8, 10);
});
it('handles negative values', () => {
const v = [-3, 4];
const normalized = normalizeVector(v);
expect(normalized[0]).toBeCloseTo(-0.6, 10);
expect(normalized[1]).toBeCloseTo(0.8, 10);
});
});
describe('findSimilar', () => {
const candidates = [
{ id: 1, embedding: [1, 0, 0] },
{ id: 2, embedding: [0.9, 0.1, 0] },
{ id: 3, embedding: [0, 1, 0] },
{ id: 4, embedding: [0, 0, 1] },
{ id: 5, embedding: [-1, 0, 0] }
];
it('returns most similar items', () => {
const query = [1, 0, 0];
const results = findSimilar(query, candidates, 3, 0);
expect(results.length).toBe(3);
expect(results[0].id).toBe(1); // Exact match
expect(results[1].id).toBe(2); // Very similar
expect(results[0].similarity).toBeCloseTo(1, 5);
});
it('respects threshold', () => {
const query = [1, 0, 0];
const results = findSimilar(query, candidates, 10, 0.8);
// Only items with similarity >= 0.8
expect(results.every((r) => r.similarity >= 0.8)).toBe(true);
});
it('respects topK limit', () => {
const query = [1, 0, 0];
const results = findSimilar(query, candidates, 2, 0);
expect(results.length).toBe(2);
});
it('returns empty array for no matches above threshold', () => {
const query = [1, 0, 0];
const results = findSimilar(query, candidates, 10, 0.999);
// Only exact match should pass 0.999 threshold
expect(results.length).toBe(1);
});
it('handles empty candidates', () => {
const query = [1, 0, 0];
const results = findSimilar(query, [], 5, 0);
expect(results).toEqual([]);
});
it('sorts by similarity descending', () => {
const query = [1, 0, 0];
const results = findSimilar(query, candidates, 5, -1);
for (let i = 1; i < results.length; i++) {
expect(results[i - 1].similarity).toBeGreaterThanOrEqual(results[i].similarity);
}
});
it('adds similarity property to results', () => {
const query = [1, 0, 0];
const results = findSimilar(query, candidates, 1, 0);
expect(results[0]).toHaveProperty('similarity');
expect(typeof results[0].similarity).toBe('number');
expect(results[0]).toHaveProperty('id');
expect(results[0]).toHaveProperty('embedding');
});
});
describe('getEmbeddingDimension', () => {
it('returns correct dimensions for known models', () => {
expect(getEmbeddingDimension('nomic-embed-text')).toBe(768);
expect(getEmbeddingDimension('mxbai-embed-large')).toBe(1024);
expect(getEmbeddingDimension('all-minilm')).toBe(384);
expect(getEmbeddingDimension('snowflake-arctic-embed')).toBe(1024);
expect(getEmbeddingDimension('embeddinggemma:latest')).toBe(768);
expect(getEmbeddingDimension('embeddinggemma')).toBe(768);
});
it('returns default 768 for unknown models', () => {
expect(getEmbeddingDimension('unknown-model')).toBe(768);
expect(getEmbeddingDimension('')).toBe(768);
expect(getEmbeddingDimension('custom-embed-model')).toBe(768);
});
});

View File

@@ -0,0 +1,187 @@
/**
* Model limits tests
*
* Tests model context window detection and capability checks
*/
import { describe, it, expect } from 'vitest';
import {
getModelContextLimit,
modelSupportsTools,
modelSupportsVision,
formatContextSize
} from './model-limits';
describe('getModelContextLimit', () => {
describe('Llama models', () => {
it('returns 128K for llama 3.2', () => {
expect(getModelContextLimit('llama3.2:8b')).toBe(128000);
expect(getModelContextLimit('llama-3.2:70b')).toBe(128000);
});
it('returns 128K for llama 3.1', () => {
expect(getModelContextLimit('llama3.1:8b')).toBe(128000);
expect(getModelContextLimit('llama-3.1:405b')).toBe(128000);
});
it('returns 8K for llama 3 base', () => {
expect(getModelContextLimit('llama3:8b')).toBe(8192);
expect(getModelContextLimit('llama-3:70b')).toBe(8192);
});
it('returns 4K for llama 2', () => {
expect(getModelContextLimit('llama2:7b')).toBe(4096);
expect(getModelContextLimit('llama-2:13b')).toBe(4096);
});
});
describe('Mistral models', () => {
it('returns 128K for mistral-large', () => {
expect(getModelContextLimit('mistral-large:latest')).toBe(128000);
});
it('returns 128K for mistral nemo', () => {
expect(getModelContextLimit('mistral-nemo:12b')).toBe(128000);
});
it('returns 32K for base mistral', () => {
expect(getModelContextLimit('mistral:7b')).toBe(32000);
expect(getModelContextLimit('mistral:latest')).toBe(32000);
});
it('returns 32K for mixtral', () => {
expect(getModelContextLimit('mixtral:8x7b')).toBe(32000);
});
});
describe('Qwen models', () => {
it('returns 128K for qwen 2.5', () => {
expect(getModelContextLimit('qwen2.5:7b')).toBe(128000);
});
it('returns 32K for qwen 2', () => {
expect(getModelContextLimit('qwen2:7b')).toBe(32000);
});
it('returns 8K for older qwen', () => {
expect(getModelContextLimit('qwen:14b')).toBe(8192);
});
});
describe('Other models', () => {
it('returns 128K for phi-3', () => {
expect(getModelContextLimit('phi-3:mini')).toBe(128000);
});
it('returns 16K for codellama', () => {
expect(getModelContextLimit('codellama:34b')).toBe(16384);
});
it('returns 200K for yi models', () => {
expect(getModelContextLimit('yi:34b')).toBe(200000);
});
it('returns 4K for llava vision models', () => {
expect(getModelContextLimit('llava:7b')).toBe(4096);
});
});
describe('Default fallback', () => {
it('returns 4K for unknown models', () => {
expect(getModelContextLimit('unknown-model:latest')).toBe(4096);
expect(getModelContextLimit('custom-finetune')).toBe(4096);
});
});
it('is case insensitive', () => {
expect(getModelContextLimit('LLAMA3.1:8B')).toBe(128000);
expect(getModelContextLimit('Mistral:Latest')).toBe(32000);
});
});
describe('modelSupportsTools', () => {
it('returns true for llama 3.1+', () => {
expect(modelSupportsTools('llama3.1:8b')).toBe(true);
expect(modelSupportsTools('llama3.2:3b')).toBe(true);
expect(modelSupportsTools('llama-3.1:70b')).toBe(true);
});
it('returns true for mistral with tool support', () => {
expect(modelSupportsTools('mistral:7b')).toBe(true);
expect(modelSupportsTools('mistral-large:latest')).toBe(true);
expect(modelSupportsTools('mistral-nemo:12b')).toBe(true);
});
it('returns true for mixtral', () => {
expect(modelSupportsTools('mixtral:8x7b')).toBe(true);
});
it('returns true for qwen2', () => {
expect(modelSupportsTools('qwen2:7b')).toBe(true);
expect(modelSupportsTools('qwen2.5:14b')).toBe(true);
});
it('returns true for command-r', () => {
expect(modelSupportsTools('command-r:latest')).toBe(true);
});
it('returns true for deepseek', () => {
expect(modelSupportsTools('deepseek-coder:6.7b')).toBe(true);
});
it('returns false for llama 3 base (no tools)', () => {
expect(modelSupportsTools('llama3:8b')).toBe(false);
});
it('returns false for older models', () => {
expect(modelSupportsTools('llama2:7b')).toBe(false);
expect(modelSupportsTools('vicuna:13b')).toBe(false);
});
});
describe('modelSupportsVision', () => {
it('returns true for llava models', () => {
expect(modelSupportsVision('llava:7b')).toBe(true);
expect(modelSupportsVision('llava:13b')).toBe(true);
});
it('returns true for bakllava', () => {
expect(modelSupportsVision('bakllava:7b')).toBe(true);
});
it('returns true for llama 3.2 vision', () => {
expect(modelSupportsVision('llama3.2-vision:11b')).toBe(true);
});
it('returns true for moondream', () => {
expect(modelSupportsVision('moondream:1.8b')).toBe(true);
});
it('returns false for text-only models', () => {
expect(modelSupportsVision('llama3:8b')).toBe(false);
expect(modelSupportsVision('mistral:7b')).toBe(false);
expect(modelSupportsVision('codellama:34b')).toBe(false);
});
});
describe('formatContextSize', () => {
it('formats large numbers with K suffix', () => {
expect(formatContextSize(128000)).toBe('128K');
expect(formatContextSize(100000)).toBe('100K');
});
it('formats medium numbers with K suffix', () => {
expect(formatContextSize(32000)).toBe('32K');
expect(formatContextSize(8192)).toBe('8K');
expect(formatContextSize(4096)).toBe('4K');
});
it('formats small numbers without suffix', () => {
expect(formatContextSize(512)).toBe('512');
expect(formatContextSize(100)).toBe('100');
});
it('rounds large numbers', () => {
expect(formatContextSize(128000)).toBe('128K');
});
});

View File

@@ -0,0 +1,214 @@
/**
* Summarizer utility tests
*
* Tests the pure functions for conversation summarization
*/
import { describe, it, expect } from 'vitest';
import {
selectMessagesForSummarization,
calculateTokenSavings,
createSummaryRecord,
shouldSummarize,
formatSummaryAsContext
} from './summarizer';
import type { MessageNode } from '$lib/types/chat';
// Helper to create message nodes
function createMessageNode(
role: 'user' | 'assistant' | 'system',
content: string,
id?: string
): MessageNode {
return {
id: id || crypto.randomUUID(),
parentId: null,
childIds: [],
createdAt: new Date(),
message: {
role,
content
}
};
}
describe('selectMessagesForSummarization', () => {
it('returns empty toSummarize when messages <= preserveCount', () => {
const messages = [
createMessageNode('user', 'Hi'),
createMessageNode('assistant', 'Hello'),
createMessageNode('user', 'How are you?'),
createMessageNode('assistant', 'Good')
];
const result = selectMessagesForSummarization(messages, 1000, 4);
expect(result.toSummarize).toHaveLength(0);
expect(result.toKeep).toHaveLength(4);
});
it('keeps recent messages and marks older for summarization', () => {
const messages = [
createMessageNode('user', 'Message 1'),
createMessageNode('assistant', 'Response 1'),
createMessageNode('user', 'Message 2'),
createMessageNode('assistant', 'Response 2'),
createMessageNode('user', 'Message 3'),
createMessageNode('assistant', 'Response 3'),
createMessageNode('user', 'Message 4'),
createMessageNode('assistant', 'Response 4')
];
const result = selectMessagesForSummarization(messages, 1000, 4);
expect(result.toSummarize).toHaveLength(4);
expect(result.toKeep).toHaveLength(4);
expect(result.toSummarize[0].message.content).toBe('Message 1');
expect(result.toKeep[0].message.content).toBe('Message 3');
});
it('preserves system messages in toKeep', () => {
const messages = [
createMessageNode('system', 'System prompt'),
createMessageNode('user', 'Message 1'),
createMessageNode('assistant', 'Response 1'),
createMessageNode('user', 'Message 2'),
createMessageNode('assistant', 'Response 2'),
createMessageNode('user', 'Message 3'),
createMessageNode('assistant', 'Response 3')
];
const result = selectMessagesForSummarization(messages, 1000, 4);
// System message should be in toKeep even though it's at the start
expect(result.toKeep.some((m) => m.message.role === 'system')).toBe(true);
expect(result.toSummarize.every((m) => m.message.role !== 'system')).toBe(true);
});
it('uses default preserveCount of 4', () => {
const messages = [
createMessageNode('user', 'M1'),
createMessageNode('assistant', 'R1'),
createMessageNode('user', 'M2'),
createMessageNode('assistant', 'R2'),
createMessageNode('user', 'M3'),
createMessageNode('assistant', 'R3'),
createMessageNode('user', 'M4'),
createMessageNode('assistant', 'R4')
];
const result = selectMessagesForSummarization(messages, 1000);
expect(result.toKeep).toHaveLength(4);
});
it('handles empty messages array', () => {
const result = selectMessagesForSummarization([], 1000);
expect(result.toSummarize).toHaveLength(0);
expect(result.toKeep).toHaveLength(0);
});
it('handles single message', () => {
const messages = [createMessageNode('user', 'Only message')];
const result = selectMessagesForSummarization(messages, 1000, 4);
expect(result.toSummarize).toHaveLength(0);
expect(result.toKeep).toHaveLength(1);
});
});
describe('calculateTokenSavings', () => {
it('calculates positive savings for longer original', () => {
const originalMessages = [
createMessageNode('user', 'This is a longer message with more words'),
createMessageNode('assistant', 'This is also a longer response with content')
];
const shortSummary = 'Brief summary.';
const savings = calculateTokenSavings(originalMessages, shortSummary);
expect(savings).toBeGreaterThan(0);
});
it('returns zero when summary is longer', () => {
const originalMessages = [createMessageNode('user', 'Hi')];
const longSummary =
'This is a very long summary that is much longer than the original message which was just a simple greeting.';
const savings = calculateTokenSavings(originalMessages, longSummary);
expect(savings).toBe(0);
});
});
describe('createSummaryRecord', () => {
it('creates a valid summary record', () => {
const record = createSummaryRecord('conv-123', 'This is the summary', 10, 500);
expect(record.id).toBeDefined();
expect(record.conversationId).toBe('conv-123');
expect(record.summary).toBe('This is the summary');
expect(record.originalMessageCount).toBe(10);
expect(record.tokensSaved).toBe(500);
expect(record.summarizedAt).toBeInstanceOf(Date);
});
it('generates unique IDs', () => {
const record1 = createSummaryRecord('conv-1', 'Summary 1', 5, 100);
const record2 = createSummaryRecord('conv-2', 'Summary 2', 5, 100);
expect(record1.id).not.toBe(record2.id);
});
});
describe('shouldSummarize', () => {
it('returns false when message count is too low', () => {
expect(shouldSummarize(8000, 10000, 4)).toBe(false);
expect(shouldSummarize(8000, 10000, 5)).toBe(false);
});
it('returns false when summarizable messages are too few', () => {
// 6 messages total - 4 preserved = 2 to summarize (minimum)
// But with < 6 total messages, should return false
expect(shouldSummarize(8000, 10000, 5)).toBe(false);
});
it('returns true when usage is high and enough messages', () => {
// 8000/10000 = 80%
expect(shouldSummarize(8000, 10000, 10)).toBe(true);
expect(shouldSummarize(9000, 10000, 8)).toBe(true);
});
it('returns false when usage is below 80%', () => {
expect(shouldSummarize(7000, 10000, 10)).toBe(false);
expect(shouldSummarize(5000, 10000, 20)).toBe(false);
});
it('returns true at exactly 80%', () => {
expect(shouldSummarize(8000, 10000, 10)).toBe(true);
});
});
describe('formatSummaryAsContext', () => {
it('formats summary as context prefix', () => {
const result = formatSummaryAsContext('User asked about weather');
expect(result).toBe('[Previous conversation summary: User asked about weather]');
});
it('handles empty summary', () => {
const result = formatSummaryAsContext('');
expect(result).toBe('[Previous conversation summary: ]');
});
it('preserves special characters in summary', () => {
const result = formatSummaryAsContext('User said "hello" & asked about <code>');
expect(result).toContain('"hello"');
expect(result).toContain('&');
expect(result).toContain('<code>');
});
});

View File

@@ -0,0 +1,191 @@
/**
* Tokenizer utility tests
*
* Tests token estimation heuristics and formatting
*/
import { describe, it, expect } from 'vitest';
import {
estimateTokensFromChars,
estimateTokensFromWords,
estimateTokens,
estimateImageTokens,
estimateMessageTokens,
estimateFormatOverhead,
estimateConversationTokens,
formatTokenCount
} from './tokenizer';
describe('estimateTokensFromChars', () => {
it('returns 0 for empty string', () => {
expect(estimateTokensFromChars('')).toBe(0);
});
it('returns 0 for null/undefined', () => {
expect(estimateTokensFromChars(null as unknown as string)).toBe(0);
expect(estimateTokensFromChars(undefined as unknown as string)).toBe(0);
});
it('estimates tokens for short text', () => {
// ~3.7 chars per token, so 10 chars ≈ 3 tokens
const result = estimateTokensFromChars('hello worl');
expect(result).toBe(3);
});
it('estimates tokens for longer text', () => {
// 100 chars / 3.7 = 27.027, rounds up to 28
const text = 'a'.repeat(100);
expect(estimateTokensFromChars(text)).toBe(28);
});
it('rounds up partial tokens', () => {
// 1 char / 3.7 = 0.27, should round to 1
expect(estimateTokensFromChars('a')).toBe(1);
});
});
describe('estimateTokensFromWords', () => {
it('returns 0 for empty string', () => {
expect(estimateTokensFromWords('')).toBe(0);
});
it('returns 0 for null/undefined', () => {
expect(estimateTokensFromWords(null as unknown as string)).toBe(0);
});
it('estimates tokens for single word', () => {
// 1 word * 1.3 = 1.3, rounds to 2
expect(estimateTokensFromWords('hello')).toBe(2);
});
it('estimates tokens for multiple words', () => {
// 5 words * 1.3 = 6.5, rounds to 7
expect(estimateTokensFromWords('the quick brown fox jumps')).toBe(7);
});
it('handles multiple spaces between words', () => {
expect(estimateTokensFromWords('hello world')).toBe(3); // 2 words * 1.3
});
it('handles leading/trailing whitespace', () => {
expect(estimateTokensFromWords(' hello world ')).toBe(3);
});
});
describe('estimateTokens', () => {
it('returns 0 for empty string', () => {
expect(estimateTokens('')).toBe(0);
});
it('returns weighted average of char and word estimates', () => {
// For "hello world" (11 chars, 2 words):
// charEstimate: 11 / 3.7 ≈ 3
// wordEstimate: 2 * 1.3 ≈ 3
// hybrid: (3 * 0.6 + 3 * 0.4) = 3
const result = estimateTokens('hello world');
expect(result).toBeGreaterThan(0);
});
it('handles code with special characters', () => {
const code = 'function test() { return 42; }';
const result = estimateTokens(code);
expect(result).toBeGreaterThan(0);
});
});
describe('estimateImageTokens', () => {
it('returns 0 for no images', () => {
expect(estimateImageTokens(0)).toBe(0);
});
it('returns 765 tokens per image', () => {
expect(estimateImageTokens(1)).toBe(765);
expect(estimateImageTokens(2)).toBe(1530);
expect(estimateImageTokens(5)).toBe(3825);
});
});
describe('estimateMessageTokens', () => {
it('handles text-only message', () => {
const result = estimateMessageTokens('hello world');
expect(result.textTokens).toBeGreaterThan(0);
expect(result.imageTokens).toBe(0);
expect(result.totalTokens).toBe(result.textTokens);
});
it('handles message with images', () => {
const result = estimateMessageTokens('hello', ['base64img1', 'base64img2']);
expect(result.textTokens).toBeGreaterThan(0);
expect(result.imageTokens).toBe(1530); // 2 * 765
expect(result.totalTokens).toBe(result.textTokens + result.imageTokens);
});
it('handles undefined images', () => {
const result = estimateMessageTokens('hello', undefined);
expect(result.imageTokens).toBe(0);
});
it('handles empty images array', () => {
const result = estimateMessageTokens('hello', []);
expect(result.imageTokens).toBe(0);
});
});
describe('estimateFormatOverhead', () => {
it('returns 0 for no messages', () => {
expect(estimateFormatOverhead(0)).toBe(0);
});
it('returns 4 tokens per message', () => {
expect(estimateFormatOverhead(1)).toBe(4);
expect(estimateFormatOverhead(5)).toBe(20);
expect(estimateFormatOverhead(10)).toBe(40);
});
});
describe('estimateConversationTokens', () => {
it('returns 0 for empty conversation', () => {
expect(estimateConversationTokens([])).toBe(0);
});
it('sums tokens across messages plus overhead', () => {
const messages = [
{ content: 'hello' },
{ content: 'world' }
];
const result = estimateConversationTokens(messages);
// Should include text tokens for both messages + 8 format overhead
expect(result).toBeGreaterThan(8);
});
it('includes image tokens', () => {
const messagesWithoutImages = [{ content: 'hello' }];
const messagesWithImages = [{ content: 'hello', images: ['img1'] }];
const withoutImages = estimateConversationTokens(messagesWithoutImages);
const withImages = estimateConversationTokens(messagesWithImages);
expect(withImages).toBe(withoutImages + 765);
});
});
describe('formatTokenCount', () => {
it('formats small numbers as-is', () => {
expect(formatTokenCount(0)).toBe('0');
expect(formatTokenCount(100)).toBe('100');
expect(formatTokenCount(999)).toBe('999');
});
it('formats thousands with K and one decimal', () => {
expect(formatTokenCount(1000)).toBe('1.0K');
expect(formatTokenCount(1500)).toBe('1.5K');
expect(formatTokenCount(2350)).toBe('2.4K'); // rounds
expect(formatTokenCount(9999)).toBe('10.0K');
});
it('formats large numbers with K and no decimal', () => {
expect(formatTokenCount(10000)).toBe('10K');
expect(formatTokenCount(50000)).toBe('50K');
expect(formatTokenCount(128000)).toBe('128K');
});
});

View File

@@ -0,0 +1,127 @@
/**
* Vector store utility tests
*
* Tests the pure utility functions
*/
import { describe, it, expect } from 'vitest';
import { formatResultsAsContext } from './vector-store';
import type { SearchResult } from './vector-store';
import type { StoredChunk, StoredDocument } from '$lib/storage/db';
// Helper to create mock search results
function createSearchResult(
documentName: string,
chunkContent: string,
similarity: number
): SearchResult {
const doc: StoredDocument = {
id: 'doc-' + Math.random().toString(36).slice(2),
name: documentName,
mimeType: 'text/plain',
size: chunkContent.length,
createdAt: Date.now(),
updatedAt: Date.now(),
chunkCount: 1,
embeddingModel: 'nomic-embed-text',
projectId: null,
embeddingStatus: 'ready'
};
const chunk: StoredChunk = {
id: 'chunk-' + Math.random().toString(36).slice(2),
documentId: doc.id,
content: chunkContent,
embedding: [],
startIndex: 0,
endIndex: chunkContent.length,
tokenCount: Math.ceil(chunkContent.split(' ').length * 1.3)
};
return { chunk, document: doc, similarity };
}
describe('formatResultsAsContext', () => {
it('formats single result correctly', () => {
const results = [createSearchResult('README.md', 'This is the content.', 0.9)];
const context = formatResultsAsContext(results);
expect(context).toContain('Relevant context from knowledge base:');
expect(context).toContain('[Source 1: README.md]');
expect(context).toContain('This is the content.');
});
it('formats multiple results with separators', () => {
const results = [
createSearchResult('doc1.txt', 'First document content', 0.95),
createSearchResult('doc2.txt', 'Second document content', 0.85),
createSearchResult('doc3.txt', 'Third document content', 0.75)
];
const context = formatResultsAsContext(results);
expect(context).toContain('[Source 1: doc1.txt]');
expect(context).toContain('[Source 2: doc2.txt]');
expect(context).toContain('[Source 3: doc3.txt]');
expect(context).toContain('First document content');
expect(context).toContain('Second document content');
expect(context).toContain('Third document content');
// Check for separators between results
expect(context.split('---').length).toBe(3);
});
it('returns empty string for empty results', () => {
const context = formatResultsAsContext([]);
expect(context).toBe('');
});
it('preserves special characters in content', () => {
const results = [
createSearchResult('code.js', 'function test() { return "hello"; }', 0.9)
];
const context = formatResultsAsContext(results);
expect(context).toContain('function test() { return "hello"; }');
});
it('includes document names in source references', () => {
const results = [
createSearchResult('path/to/file.md', 'Some content', 0.9)
];
const context = formatResultsAsContext(results);
expect(context).toContain('[Source 1: path/to/file.md]');
});
it('numbers sources sequentially', () => {
const results = [
createSearchResult('a.txt', 'Content A', 0.9),
createSearchResult('b.txt', 'Content B', 0.8),
createSearchResult('c.txt', 'Content C', 0.7),
createSearchResult('d.txt', 'Content D', 0.6),
createSearchResult('e.txt', 'Content E', 0.5)
];
const context = formatResultsAsContext(results);
expect(context).toContain('[Source 1: a.txt]');
expect(context).toContain('[Source 2: b.txt]');
expect(context).toContain('[Source 3: c.txt]');
expect(context).toContain('[Source 4: d.txt]');
expect(context).toContain('[Source 5: e.txt]');
});
it('handles multiline content', () => {
const results = [
createSearchResult('notes.txt', 'Line 1\nLine 2\nLine 3', 0.9)
];
const context = formatResultsAsContext(results);
expect(context).toContain('Line 1\nLine 2\nLine 3');
});
});

View File

@@ -0,0 +1,381 @@
/**
* OllamaClient tests
*
* Tests the Ollama API client with mocked fetch
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OllamaClient } from './client';
// Helper to create mock fetch response
function mockResponse(data: unknown, status = 200, ok = true): Response {
return {
ok,
status,
statusText: ok ? 'OK' : 'Error',
json: async () => data,
text: async () => JSON.stringify(data),
headers: new Headers({ 'Content-Type': 'application/json' }),
clone: () => mockResponse(data, status, ok)
} as Response;
}
// Helper to create streaming response
function mockStreamResponse(chunks: unknown[]): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(JSON.stringify(chunk) + '\n'));
}
controller.close();
}
});
return {
ok: true,
status: 200,
body: stream,
headers: new Headers()
} as Response;
}
describe('OllamaClient', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockFetch: any;
let client: OllamaClient;
beforeEach(() => {
mockFetch = vi.fn();
client = new OllamaClient({
baseUrl: 'http://localhost:11434',
fetchFn: mockFetch,
enableRetry: false
});
});
describe('constructor', () => {
it('uses default config when not provided', () => {
const defaultClient = new OllamaClient({ fetchFn: mockFetch });
expect(defaultClient.baseUrl).toBe('');
});
it('uses custom base URL', () => {
expect(client.baseUrl).toBe('http://localhost:11434');
});
});
describe('listModels', () => {
it('fetches models list', async () => {
const models = {
models: [
{ name: 'llama3:8b', size: 4000000000 },
{ name: 'mistral:7b', size: 3500000000 }
]
};
mockFetch.mockResolvedValueOnce(mockResponse(models));
const result = await client.listModels();
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:11434/api/tags',
expect.objectContaining({ method: 'GET' })
);
expect(result.models).toHaveLength(2);
expect(result.models[0].name).toBe('llama3:8b');
});
});
describe('listRunningModels', () => {
it('fetches running models', async () => {
const running = {
models: [{ name: 'llama3:8b', size: 4000000000 }]
};
mockFetch.mockResolvedValueOnce(mockResponse(running));
const result = await client.listRunningModels();
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:11434/api/ps',
expect.objectContaining({ method: 'GET' })
);
expect(result.models).toHaveLength(1);
});
});
describe('showModel', () => {
it('fetches model details with string arg', async () => {
const details = {
modelfile: 'FROM llama3',
parameters: 'temperature 0.8'
};
mockFetch.mockResolvedValueOnce(mockResponse(details));
const result = await client.showModel('llama3:8b');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:11434/api/show',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ model: 'llama3:8b' })
})
);
expect(result.modelfile).toBe('FROM llama3');
});
it('fetches model details with request object', async () => {
const details = { modelfile: 'FROM llama3' };
mockFetch.mockResolvedValueOnce(mockResponse(details));
await client.showModel({ model: 'llama3:8b', verbose: true });
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:11434/api/show',
expect.objectContaining({
body: JSON.stringify({ model: 'llama3:8b', verbose: true })
})
);
});
});
describe('deleteModel', () => {
it('sends delete request', async () => {
mockFetch.mockResolvedValueOnce(mockResponse({}));
await client.deleteModel('old-model');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:11434/api/delete',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ name: 'old-model' })
})
);
});
});
describe('pullModel', () => {
it('streams pull progress', async () => {
const chunks = [
{ status: 'pulling manifest' },
{ status: 'downloading', completed: 50, total: 100 },
{ status: 'success' }
];
mockFetch.mockResolvedValueOnce(mockStreamResponse(chunks));
const progress: unknown[] = [];
await client.pullModel('llama3:8b', (p) => progress.push(p));
expect(progress).toHaveLength(3);
expect(progress[0]).toEqual({ status: 'pulling manifest' });
expect(progress[2]).toEqual({ status: 'success' });
});
});
describe('createModel', () => {
it('streams create progress', async () => {
const chunks = [
{ status: 'creating new layer sha256:abc...' },
{ status: 'writing manifest' },
{ status: 'success' }
];
mockFetch.mockResolvedValueOnce(mockStreamResponse(chunks));
const progress: unknown[] = [];
await client.createModel(
{ model: 'my-custom', from: 'llama3:8b', system: 'You are helpful' },
(p) => progress.push(p)
);
expect(progress).toHaveLength(3);
expect(progress[2]).toEqual({ status: 'success' });
});
});
describe('chat', () => {
it('sends chat request', async () => {
const response = {
message: { role: 'assistant', content: 'Hello!' },
done: true
};
mockFetch.mockResolvedValueOnce(mockResponse(response));
const result = await client.chat({
model: 'llama3:8b',
messages: [{ role: 'user', content: 'Hi' }]
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:11434/api/chat',
expect.objectContaining({
method: 'POST'
})
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.model).toBe('llama3:8b');
expect(body.stream).toBe(false);
expect(result.message.content).toBe('Hello!');
});
it('includes tools in request', async () => {
mockFetch.mockResolvedValueOnce(
mockResponse({ message: { role: 'assistant', content: 'ok' }, done: true })
);
await client.chat({
model: 'llama3:8b',
messages: [{ role: 'user', content: 'test' }],
tools: [
{
type: 'function',
function: {
name: 'get_time',
description: 'Get current time',
parameters: { type: 'object', properties: {} }
}
}
]
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.tools).toHaveLength(1);
expect(body.tools[0].function.name).toBe('get_time');
});
it('includes options in request', async () => {
mockFetch.mockResolvedValueOnce(
mockResponse({ message: { role: 'assistant', content: 'ok' }, done: true })
);
await client.chat({
model: 'llama3:8b',
messages: [{ role: 'user', content: 'test' }],
options: { temperature: 0.5, num_ctx: 4096 }
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.options.temperature).toBe(0.5);
expect(body.options.num_ctx).toBe(4096);
});
it('includes think option for reasoning models', async () => {
mockFetch.mockResolvedValueOnce(
mockResponse({ message: { role: 'assistant', content: 'ok' }, done: true })
);
await client.chat({
model: 'qwen3:8b',
messages: [{ role: 'user', content: 'test' }],
think: true
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.think).toBe(true);
});
});
describe('generate', () => {
it('sends generate request', async () => {
const response = { response: 'Generated text', done: true };
mockFetch.mockResolvedValueOnce(mockResponse(response));
const result = await client.generate({
model: 'llama3:8b',
prompt: 'Complete this: Hello'
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.stream).toBe(false);
expect(result.response).toBe('Generated text');
});
});
describe('embed', () => {
it('generates embeddings', async () => {
const response = { embeddings: [[0.1, 0.2, 0.3]] };
mockFetch.mockResolvedValueOnce(mockResponse(response));
const result = await client.embed({
model: 'nomic-embed-text',
input: 'test text'
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:11434/api/embed',
expect.objectContaining({ method: 'POST' })
);
expect(result.embeddings[0]).toHaveLength(3);
});
});
describe('healthCheck', () => {
it('returns true when server responds', async () => {
mockFetch.mockResolvedValueOnce(mockResponse({ version: '0.3.0' }));
const healthy = await client.healthCheck();
expect(healthy).toBe(true);
});
it('returns false when server fails', async () => {
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
const healthy = await client.healthCheck();
expect(healthy).toBe(false);
});
});
describe('getVersion', () => {
it('returns version info', async () => {
mockFetch.mockResolvedValueOnce(mockResponse({ version: '0.3.0' }));
const result = await client.getVersion();
expect(result.version).toBe('0.3.0');
});
});
describe('testConnection', () => {
it('returns success status when connected', async () => {
mockFetch.mockResolvedValueOnce(mockResponse({ version: '0.3.0' }));
const status = await client.testConnection();
expect(status.connected).toBe(true);
expect(status.version).toBe('0.3.0');
expect(status.latencyMs).toBeGreaterThanOrEqual(0);
expect(status.baseUrl).toBe('http://localhost:11434');
});
it('returns error status when disconnected', async () => {
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
const status = await client.testConnection();
expect(status.connected).toBe(false);
expect(status.error).toBeDefined();
expect(status.latencyMs).toBeGreaterThanOrEqual(0);
});
});
describe('withConfig', () => {
it('creates new client with updated config', () => {
const newClient = client.withConfig({ baseUrl: 'http://other:11434' });
expect(newClient.baseUrl).toBe('http://other:11434');
expect(client.baseUrl).toBe('http://localhost:11434'); // Original unchanged
});
});
describe('error handling', () => {
it('throws on non-ok response', async () => {
mockFetch.mockResolvedValueOnce(
mockResponse({ error: 'Model not found' }, 404, false)
);
await expect(client.listModels()).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,264 @@
/**
* Ollama error handling tests
*
* Tests error classification, error types, and retry logic
*/
import { describe, it, expect, vi } from 'vitest';
import {
OllamaError,
OllamaConnectionError,
OllamaTimeoutError,
OllamaModelNotFoundError,
OllamaInvalidRequestError,
OllamaStreamError,
OllamaParseError,
OllamaAbortError,
classifyError,
withRetry
} from './errors';
describe('OllamaError', () => {
it('creates error with code and message', () => {
const error = new OllamaError('Something went wrong', 'UNKNOWN_ERROR');
expect(error.message).toBe('Something went wrong');
expect(error.code).toBe('UNKNOWN_ERROR');
expect(error.name).toBe('OllamaError');
});
it('stores status code when provided', () => {
const error = new OllamaError('Server error', 'SERVER_ERROR', { statusCode: 500 });
expect(error.statusCode).toBe(500);
});
it('stores original error as cause', () => {
const originalError = new Error('Original');
const error = new OllamaError('Wrapped', 'UNKNOWN_ERROR', { cause: originalError });
expect(error.originalError).toBe(originalError);
expect(error.cause).toBe(originalError);
});
describe('isRetryable', () => {
it('returns true for CONNECTION_ERROR', () => {
const error = new OllamaError('Connection failed', 'CONNECTION_ERROR');
expect(error.isRetryable).toBe(true);
});
it('returns true for TIMEOUT_ERROR', () => {
const error = new OllamaError('Timed out', 'TIMEOUT_ERROR');
expect(error.isRetryable).toBe(true);
});
it('returns true for SERVER_ERROR', () => {
const error = new OllamaError('Server down', 'SERVER_ERROR');
expect(error.isRetryable).toBe(true);
});
it('returns false for INVALID_REQUEST', () => {
const error = new OllamaError('Bad request', 'INVALID_REQUEST');
expect(error.isRetryable).toBe(false);
});
it('returns false for MODEL_NOT_FOUND', () => {
const error = new OllamaError('Model missing', 'MODEL_NOT_FOUND');
expect(error.isRetryable).toBe(false);
});
});
});
describe('Specialized Error Classes', () => {
it('OllamaConnectionError has correct code', () => {
const error = new OllamaConnectionError('Cannot connect');
expect(error.code).toBe('CONNECTION_ERROR');
expect(error.name).toBe('OllamaConnectionError');
});
it('OllamaTimeoutError stores timeout value', () => {
const error = new OllamaTimeoutError('Request timed out', 30000);
expect(error.code).toBe('TIMEOUT_ERROR');
expect(error.timeoutMs).toBe(30000);
});
it('OllamaModelNotFoundError stores model name', () => {
const error = new OllamaModelNotFoundError('llama3:8b');
expect(error.code).toBe('MODEL_NOT_FOUND');
expect(error.modelName).toBe('llama3:8b');
expect(error.message).toContain('llama3:8b');
});
it('OllamaInvalidRequestError has 400 status', () => {
const error = new OllamaInvalidRequestError('Missing required field');
expect(error.code).toBe('INVALID_REQUEST');
expect(error.statusCode).toBe(400);
});
it('OllamaStreamError preserves cause', () => {
const cause = new Error('Stream interrupted');
const error = new OllamaStreamError('Streaming failed', cause);
expect(error.code).toBe('STREAM_ERROR');
expect(error.originalError).toBe(cause);
});
it('OllamaParseError stores raw data', () => {
const error = new OllamaParseError('Invalid JSON', '{"broken');
expect(error.code).toBe('PARSE_ERROR');
expect(error.rawData).toBe('{"broken');
});
it('OllamaAbortError has default message', () => {
const error = new OllamaAbortError();
expect(error.code).toBe('ABORT_ERROR');
expect(error.message).toBe('Request was aborted');
});
});
describe('classifyError', () => {
it('returns OllamaError unchanged', () => {
const original = new OllamaConnectionError('Already classified');
const result = classifyError(original);
expect(result).toBe(original);
});
it('classifies TypeError with fetch as connection error', () => {
const error = new TypeError('Failed to fetch');
const result = classifyError(error);
expect(result).toBeInstanceOf(OllamaConnectionError);
expect(result.code).toBe('CONNECTION_ERROR');
});
it('classifies TypeError with network as connection error', () => {
const error = new TypeError('network error');
const result = classifyError(error);
expect(result).toBeInstanceOf(OllamaConnectionError);
});
it('classifies AbortError DOMException', () => {
const error = new DOMException('Aborted', 'AbortError');
const result = classifyError(error);
expect(result).toBeInstanceOf(OllamaAbortError);
});
it('classifies ECONNREFUSED as connection error', () => {
const error = new Error('connect ECONNREFUSED 127.0.0.1:11434');
const result = classifyError(error);
expect(result).toBeInstanceOf(OllamaConnectionError);
});
it('classifies timeout messages as timeout error', () => {
const error = new Error('Request timed out');
const result = classifyError(error);
expect(result).toBeInstanceOf(OllamaTimeoutError);
});
it('classifies abort messages as abort error', () => {
const error = new Error('Operation aborted by user');
const result = classifyError(error);
expect(result).toBeInstanceOf(OllamaAbortError);
});
it('adds context prefix when provided', () => {
const error = new Error('Something failed');
const result = classifyError(error, 'During chat');
expect(result.message).toContain('During chat:');
});
it('handles non-Error values', () => {
const result = classifyError('just a string');
expect(result).toBeInstanceOf(OllamaError);
expect(result.code).toBe('UNKNOWN_ERROR');
expect(result.message).toContain('just a string');
});
it('handles null/undefined', () => {
expect(classifyError(null).code).toBe('UNKNOWN_ERROR');
expect(classifyError(undefined).code).toBe('UNKNOWN_ERROR');
});
});
describe('withRetry', () => {
it('returns result on first success', async () => {
const fn = vi.fn().mockResolvedValue('success');
const result = await withRetry(fn);
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries on retryable error', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new OllamaConnectionError('Failed'))
.mockResolvedValueOnce('success');
const result = await withRetry(fn, { initialDelayMs: 1 });
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
});
it('does not retry non-retryable errors', async () => {
const fn = vi.fn().mockRejectedValue(new OllamaInvalidRequestError('Bad request'));
await expect(withRetry(fn)).rejects.toThrow(OllamaInvalidRequestError);
expect(fn).toHaveBeenCalledTimes(1);
});
it('stops after maxAttempts', async () => {
const fn = vi.fn().mockRejectedValue(new OllamaConnectionError('Always fails'));
await expect(withRetry(fn, { maxAttempts: 3, initialDelayMs: 1 })).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(3);
});
it('calls onRetry callback', async () => {
const onRetry = vi.fn();
const fn = vi
.fn()
.mockRejectedValueOnce(new OllamaConnectionError('Failed'))
.mockResolvedValueOnce('ok');
await withRetry(fn, { initialDelayMs: 1, onRetry });
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(expect.any(OllamaConnectionError), 1, 1);
});
it('respects abort signal', async () => {
const controller = new AbortController();
controller.abort();
const fn = vi.fn().mockResolvedValue('success');
await expect(withRetry(fn, { signal: controller.signal })).rejects.toThrow(OllamaAbortError);
expect(fn).not.toHaveBeenCalled();
});
it('uses custom isRetryable function', async () => {
// Make MODEL_NOT_FOUND retryable (not normally)
const fn = vi
.fn()
.mockRejectedValueOnce(new OllamaModelNotFoundError('test-model'))
.mockResolvedValueOnce('found it');
const result = await withRetry(fn, {
initialDelayMs: 1,
isRetryable: () => true // Retry everything
});
expect(result).toBe('found it');
expect(fn).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,173 @@
/**
* Modelfile parser tests
*
* Tests parsing of Ollama Modelfile format directives
*/
import { describe, it, expect } from 'vitest';
import {
parseSystemPromptFromModelfile,
parseTemplateFromModelfile,
parseParametersFromModelfile,
hasSystemPrompt
} from './modelfile-parser';
describe('parseSystemPromptFromModelfile', () => {
it('returns null for empty input', () => {
expect(parseSystemPromptFromModelfile('')).toBeNull();
expect(parseSystemPromptFromModelfile(null as unknown as string)).toBeNull();
});
it('parses triple double quoted system prompt', () => {
const modelfile = `FROM llama3
SYSTEM """
You are a helpful assistant.
Be concise and clear.
"""
PARAMETER temperature 0.7`;
const result = parseSystemPromptFromModelfile(modelfile);
expect(result).toBe('You are a helpful assistant.\nBe concise and clear.');
});
it('parses triple single quoted system prompt', () => {
const modelfile = `FROM llama3
SYSTEM '''
You are a coding assistant.
'''`;
const result = parseSystemPromptFromModelfile(modelfile);
expect(result).toBe('You are a coding assistant.');
});
it('parses double quoted single-line system prompt', () => {
const modelfile = `FROM llama3
SYSTEM "You are a helpful assistant."`;
const result = parseSystemPromptFromModelfile(modelfile);
expect(result).toBe('You are a helpful assistant.');
});
it('parses single quoted single-line system prompt', () => {
const modelfile = `FROM mistral
SYSTEM 'Be brief and accurate.'`;
const result = parseSystemPromptFromModelfile(modelfile);
expect(result).toBe('Be brief and accurate.');
});
it('parses unquoted system prompt', () => {
const modelfile = `FROM llama3
SYSTEM You are a helpful AI`;
const result = parseSystemPromptFromModelfile(modelfile);
expect(result).toBe('You are a helpful AI');
});
it('returns null when no system directive', () => {
const modelfile = `FROM llama3
PARAMETER temperature 0.8`;
expect(parseSystemPromptFromModelfile(modelfile)).toBeNull();
});
it('is case insensitive', () => {
const modelfile = `system "Lower case works too"`;
expect(parseSystemPromptFromModelfile(modelfile)).toBe('Lower case works too');
});
});
describe('parseTemplateFromModelfile', () => {
it('returns null for empty input', () => {
expect(parseTemplateFromModelfile('')).toBeNull();
});
it('parses triple quoted template', () => {
const modelfile = `FROM llama3
TEMPLATE """{{ .System }}
{{ .Prompt }}"""`;
const result = parseTemplateFromModelfile(modelfile);
expect(result).toBe('{{ .System }}\n{{ .Prompt }}');
});
it('parses single-line template', () => {
const modelfile = `FROM mistral
TEMPLATE "{{ .Prompt }}"`;
const result = parseTemplateFromModelfile(modelfile);
expect(result).toBe('{{ .Prompt }}');
});
it('returns null when no template', () => {
const modelfile = `FROM llama3
SYSTEM "Hello"`;
expect(parseTemplateFromModelfile(modelfile)).toBeNull();
});
});
describe('parseParametersFromModelfile', () => {
it('returns empty object for empty input', () => {
expect(parseParametersFromModelfile('')).toEqual({});
});
it('parses single parameter', () => {
const modelfile = `FROM llama3
PARAMETER temperature 0.7`;
const result = parseParametersFromModelfile(modelfile);
expect(result).toEqual({ temperature: '0.7' });
});
it('parses multiple parameters', () => {
const modelfile = `FROM llama3
PARAMETER temperature 0.8
PARAMETER top_k 40
PARAMETER top_p 0.9
PARAMETER num_ctx 4096`;
const result = parseParametersFromModelfile(modelfile);
expect(result).toEqual({
temperature: '0.8',
top_k: '40',
top_p: '0.9',
num_ctx: '4096'
});
});
it('normalizes parameter names to lowercase', () => {
const modelfile = `PARAMETER Temperature 0.5
PARAMETER TOP_K 50`;
const result = parseParametersFromModelfile(modelfile);
expect(result.temperature).toBe('0.5');
expect(result.top_k).toBe('50');
});
it('handles mixed content', () => {
const modelfile = `FROM mistral
SYSTEM "Be helpful"
PARAMETER temperature 0.7
TEMPLATE "{{ .Prompt }}"
PARAMETER num_ctx 8192`;
const result = parseParametersFromModelfile(modelfile);
expect(result).toEqual({
temperature: '0.7',
num_ctx: '8192'
});
});
});
describe('hasSystemPrompt', () => {
it('returns true when system prompt exists', () => {
expect(hasSystemPrompt('SYSTEM "Hello"')).toBe(true);
expect(hasSystemPrompt('SYSTEM """Multi\nline"""')).toBe(true);
});
it('returns false when no system prompt', () => {
expect(hasSystemPrompt('FROM llama3')).toBe(false);
expect(hasSystemPrompt('')).toBe(false);
});
});

View File

@@ -0,0 +1,131 @@
/**
* Conversation Summary Service tests
*
* Tests the pure utility functions for conversation summaries
*/
import { describe, it, expect } from 'vitest';
import { getSummaryPrompt } from './conversation-summary';
import type { Message } from '$lib/types/chat';
// Helper to create messages
function createMessage(
role: 'user' | 'assistant' | 'system',
content: string
): Message {
return {
role,
content
};
}
describe('getSummaryPrompt', () => {
it('formats user and assistant messages correctly', () => {
const messages: Message[] = [
createMessage('user', 'Hello!'),
createMessage('assistant', 'Hi there!')
];
const prompt = getSummaryPrompt(messages);
expect(prompt).toContain('User: Hello!');
expect(prompt).toContain('Assistant: Hi there!');
expect(prompt).toContain('Summarize this conversation');
});
it('filters out system messages', () => {
const messages: Message[] = [
createMessage('system', 'You are a helpful assistant'),
createMessage('user', 'Hello!'),
createMessage('assistant', 'Hi!')
];
const prompt = getSummaryPrompt(messages);
expect(prompt).not.toContain('You are a helpful assistant');
expect(prompt).toContain('User: Hello!');
});
it('respects maxMessages limit', () => {
const messages: Message[] = [
createMessage('user', 'Message 1'),
createMessage('assistant', 'Response 1'),
createMessage('user', 'Message 2'),
createMessage('assistant', 'Response 2'),
createMessage('user', 'Message 3'),
createMessage('assistant', 'Response 3')
];
const prompt = getSummaryPrompt(messages, 4);
// Should only include last 4 messages
expect(prompt).not.toContain('Message 1');
expect(prompt).not.toContain('Response 1');
expect(prompt).toContain('Message 2');
expect(prompt).toContain('Response 2');
expect(prompt).toContain('Message 3');
expect(prompt).toContain('Response 3');
});
it('truncates long message content to 500 chars', () => {
const longContent = 'A'.repeat(600);
const messages: Message[] = [createMessage('user', longContent)];
const prompt = getSummaryPrompt(messages);
// Content should be truncated
expect(prompt).not.toContain('A'.repeat(600));
expect(prompt).toContain('A'.repeat(500));
});
it('handles empty messages array', () => {
const prompt = getSummaryPrompt([]);
expect(prompt).toContain('Summarize this conversation');
expect(prompt).toContain('Conversation:');
});
it('includes standard prompt instructions', () => {
const messages: Message[] = [
createMessage('user', 'Test'),
createMessage('assistant', 'Test response')
];
const prompt = getSummaryPrompt(messages);
expect(prompt).toContain('Summarize this conversation in 2-3 sentences');
expect(prompt).toContain('Focus on the main topics');
expect(prompt).toContain('Be concise');
expect(prompt).toContain('Summary:');
});
it('uses default maxMessages of 20', () => {
// Create 25 messages with distinct identifiers to avoid substring matches
const messages: Message[] = [];
for (let i = 0; i < 25; i++) {
// Use letters to avoid number substring issues (Message 1 in Message 10)
const letter = String.fromCharCode(65 + i); // A, B, C, ...
messages.push(createMessage(i % 2 === 0 ? 'user' : 'assistant', `Msg-${letter}`));
}
const prompt = getSummaryPrompt(messages);
// First 5 messages should not be included (25 - 20 = 5)
expect(prompt).not.toContain('Msg-A');
expect(prompt).not.toContain('Msg-E');
// Message 6 onwards should be included
expect(prompt).toContain('Msg-F');
expect(prompt).toContain('Msg-Y'); // 25th message
});
it('separates messages with double newlines', () => {
const messages: Message[] = [
createMessage('user', 'First'),
createMessage('assistant', 'Second')
];
const prompt = getSummaryPrompt(messages);
expect(prompt).toContain('User: First\n\nAssistant: Second');
});
});

View File

@@ -15,7 +15,7 @@ import { indexConversationMessages } from './chat-indexer.js';
export interface SummaryGenerationOptions {
/** Model to use for summary generation */
model: string;
/** Base URL for Ollama API */
/** Base URL for Ollama API (default: /api/v1/ollama, uses proxy) */
baseUrl?: string;
/** Maximum messages to include in summary context */
maxMessages?: number;
@@ -37,7 +37,7 @@ export async function generateConversationSummary(
messages: Message[],
options: SummaryGenerationOptions
): Promise<string> {
const { model, baseUrl = 'http://localhost:11434', maxMessages = 20 } = options;
const { model, baseUrl = '/api/v1/ollama', maxMessages = 20 } = options;
// Filter to user and assistant messages only
const relevantMessages = messages

View File

@@ -0,0 +1,47 @@
/**
* Prompt resolution service tests
*
* Tests the pure utility functions from prompt resolution
*/
import { describe, it, expect } from 'vitest';
import { getPromptSourceLabel, type PromptSource } from './prompt-resolution';
describe('getPromptSourceLabel', () => {
const testCases: Array<{ source: PromptSource; expected: string }> = [
{ source: 'per-conversation', expected: 'Custom (this chat)' },
{ source: 'new-chat-selection', expected: 'Selected prompt' },
{ source: 'agent', expected: 'Agent prompt' },
{ source: 'model-mapping', expected: 'Model default' },
{ source: 'model-embedded', expected: 'Model built-in' },
{ source: 'capability-match', expected: 'Auto-matched' },
{ source: 'global-active', expected: 'Global default' },
{ source: 'none', expected: 'None' }
];
testCases.forEach(({ source, expected }) => {
it(`returns "${expected}" for source "${source}"`, () => {
expect(getPromptSourceLabel(source)).toBe(expected);
});
});
it('covers all prompt source types', () => {
// This ensures we test all PromptSource values
const allSources: PromptSource[] = [
'per-conversation',
'new-chat-selection',
'agent',
'model-mapping',
'model-embedded',
'capability-match',
'global-active',
'none'
];
allSources.forEach((source) => {
const label = getPromptSourceLabel(source);
expect(typeof label).toBe('string');
expect(label.length).toBeGreaterThan(0);
});
});
});

View File

@@ -20,6 +20,7 @@ import type { OllamaCapability } from '$lib/ollama/types.js';
export type PromptSource =
| 'per-conversation'
| 'new-chat-selection'
| 'agent'
| 'model-mapping'
| 'model-embedded'
| 'capability-match'
@@ -72,21 +73,26 @@ function findCapabilityMatchedPrompt(
* 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
* 3. Agent prompt (if agent is specified and has a promptId)
* 4. Model-prompt mapping (user configured default for model)
* 5. Model-embedded prompt (from Ollama Modelfile)
* 6. Capability-matched prompt
* 7. Global active prompt
* 8. 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)
* @param agentPromptId - Agent's prompt ID (if agent is selected)
* @param agentName - Agent's name for display (optional)
* @returns Resolved prompt with content and source
*/
export async function resolveSystemPrompt(
modelName: string,
conversationPromptId?: string | null,
newChatPromptId?: string | null
newChatPromptId?: string | null,
agentPromptId?: string | null,
agentName?: string
): Promise<ResolvedPrompt> {
// Ensure stores are loaded
await promptsState.ready();
@@ -116,7 +122,19 @@ export async function resolveSystemPrompt(
}
}
// 3. User-configured model-prompt mapping
// 3. Agent prompt (if agent is specified and has a promptId)
if (agentPromptId) {
const prompt = promptsState.get(agentPromptId);
if (prompt) {
return {
content: prompt.content,
source: 'agent',
promptName: agentName ? `${agentName}: ${prompt.name}` : prompt.name
};
}
}
// 4. User-configured model-prompt mapping
const mappedPromptId = modelPromptMappingsState.getMapping(modelName);
if (mappedPromptId) {
const prompt = promptsState.get(mappedPromptId);
@@ -129,7 +147,7 @@ export async function resolveSystemPrompt(
}
}
// 4. Model-embedded prompt (from Ollama Modelfile SYSTEM directive)
// 5. Model-embedded prompt (from Ollama Modelfile SYSTEM directive)
const modelInfo = await modelInfoService.getModelInfo(modelName);
if (modelInfo.systemPrompt) {
return {
@@ -139,7 +157,7 @@ export async function resolveSystemPrompt(
};
}
// 5. Capability-matched prompt
// 6. Capability-matched prompt
if (modelInfo.capabilities.length > 0) {
const capabilityMatch = findCapabilityMatchedPrompt(modelInfo.capabilities, promptsState.prompts);
if (capabilityMatch) {
@@ -152,7 +170,7 @@ export async function resolveSystemPrompt(
}
}
// 6. Global active prompt
// 7. Global active prompt
const activePrompt = promptsState.activePrompt;
if (activePrompt) {
return {
@@ -162,7 +180,7 @@ export async function resolveSystemPrompt(
};
}
// 7. No prompt
// 8. No prompt
return {
content: '',
source: 'none'
@@ -181,6 +199,8 @@ export function getPromptSourceLabel(source: PromptSource): string {
return 'Custom (this chat)';
case 'new-chat-selection':
return 'Selected prompt';
case 'agent':
return 'Agent prompt';
case 'model-mapping':
return 'Model default';
case 'model-embedded':

View File

@@ -0,0 +1,366 @@
/**
* Agents storage layer tests
*
* Tests CRUD operations for agents and project-agent relationships.
* Uses fake-indexeddb for in-memory IndexedDB testing.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import 'fake-indexeddb/auto';
import { db, generateId } from './db.js';
import {
createAgent,
getAllAgents,
getAgent,
updateAgent,
deleteAgent,
assignAgentToProject,
removeAgentFromProject,
getAgentsForProject
} from './agents.js';
describe('agents storage', () => {
// Reset database before each test
beforeEach(async () => {
// Clear all agent-related tables
await db.agents.clear();
await db.projectAgents.clear();
});
afterEach(async () => {
await db.agents.clear();
await db.projectAgents.clear();
});
describe('createAgent', () => {
it('creates agent with required fields', async () => {
const result = await createAgent({
name: 'Test Agent',
description: 'A test agent'
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Test Agent');
expect(result.data.description).toBe('A test agent');
}
});
it('generates unique id', async () => {
const result1 = await createAgent({ name: 'Agent 1', description: '' });
const result2 = await createAgent({ name: 'Agent 2', description: '' });
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
if (result1.success && result2.success) {
expect(result1.data.id).not.toBe(result2.data.id);
}
});
it('sets createdAt and updatedAt timestamps', async () => {
const before = Date.now();
const result = await createAgent({ name: 'Agent', description: '' });
const after = Date.now();
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.createdAt.getTime()).toBeGreaterThanOrEqual(before);
expect(result.data.createdAt.getTime()).toBeLessThanOrEqual(after);
expect(result.data.updatedAt.getTime()).toBe(result.data.createdAt.getTime());
}
});
it('stores optional promptId', async () => {
const promptId = generateId();
const result = await createAgent({
name: 'Agent with Prompt',
description: '',
promptId
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.promptId).toBe(promptId);
}
});
it('stores enabledToolNames array', async () => {
const tools = ['fetch_url', 'web_search', 'calculate'];
const result = await createAgent({
name: 'Agent with Tools',
description: '',
enabledToolNames: tools
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.enabledToolNames).toEqual(tools);
}
});
it('stores optional preferredModel', async () => {
const result = await createAgent({
name: 'Agent with Model',
description: '',
preferredModel: 'llama3.2:8b'
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.preferredModel).toBe('llama3.2:8b');
}
});
it('defaults optional fields appropriately', async () => {
const result = await createAgent({
name: 'Minimal Agent',
description: 'Just the basics'
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.promptId).toBeNull();
expect(result.data.enabledToolNames).toEqual([]);
expect(result.data.preferredModel).toBeNull();
}
});
});
describe('getAllAgents', () => {
it('returns empty array when no agents', async () => {
const result = await getAllAgents();
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([]);
}
});
it('returns all agents sorted by name', async () => {
await createAgent({ name: 'Charlie', description: '' });
await createAgent({ name: 'Alice', description: '' });
await createAgent({ name: 'Bob', description: '' });
const result = await getAllAgents();
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.length).toBe(3);
expect(result.data[0].name).toBe('Alice');
expect(result.data[1].name).toBe('Bob');
expect(result.data[2].name).toBe('Charlie');
}
});
});
describe('getAgent', () => {
it('returns agent by id', async () => {
const createResult = await createAgent({ name: 'Test Agent', description: 'desc' });
expect(createResult.success).toBe(true);
if (!createResult.success) return;
const result = await getAgent(createResult.data.id);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data?.name).toBe('Test Agent');
expect(result.data?.description).toBe('desc');
}
});
it('returns null for non-existent id', async () => {
const result = await getAgent('non-existent-id');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
});
describe('updateAgent', () => {
it('updates name', async () => {
const createResult = await createAgent({ name: 'Original', description: '' });
expect(createResult.success).toBe(true);
if (!createResult.success) return;
const result = await updateAgent(createResult.data.id, { name: 'Updated' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Updated');
}
});
it('updates enabledToolNames', async () => {
const createResult = await createAgent({
name: 'Agent',
description: '',
enabledToolNames: ['tool1']
});
expect(createResult.success).toBe(true);
if (!createResult.success) return;
const result = await updateAgent(createResult.data.id, {
enabledToolNames: ['tool1', 'tool2', 'tool3']
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.enabledToolNames).toEqual(['tool1', 'tool2', 'tool3']);
}
});
it('updates updatedAt timestamp', async () => {
const createResult = await createAgent({ name: 'Agent', description: '' });
expect(createResult.success).toBe(true);
if (!createResult.success) return;
const originalUpdatedAt = createResult.data.updatedAt.getTime();
// Small delay to ensure timestamp differs
await new Promise((r) => setTimeout(r, 10));
const result = await updateAgent(createResult.data.id, { description: 'new description' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.updatedAt.getTime()).toBeGreaterThan(originalUpdatedAt);
}
});
it('returns error for non-existent agent', async () => {
const result = await updateAgent('non-existent-id', { name: 'Updated' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('not found');
}
});
});
describe('deleteAgent', () => {
it('removes agent from database', async () => {
const createResult = await createAgent({ name: 'To Delete', description: '' });
expect(createResult.success).toBe(true);
if (!createResult.success) return;
const deleteResult = await deleteAgent(createResult.data.id);
expect(deleteResult.success).toBe(true);
const getResult = await getAgent(createResult.data.id);
expect(getResult.success).toBe(true);
if (getResult.success) {
expect(getResult.data).toBeNull();
}
});
it('removes project-agent associations', async () => {
const createResult = await createAgent({ name: 'Agent', description: '' });
expect(createResult.success).toBe(true);
if (!createResult.success) return;
const projectId = generateId();
await assignAgentToProject(createResult.data.id, projectId);
// Verify assignment exists
let agents = await getAgentsForProject(projectId);
expect(agents.success).toBe(true);
if (agents.success) {
expect(agents.data.length).toBe(1);
}
// Delete agent
await deleteAgent(createResult.data.id);
// Verify association removed
agents = await getAgentsForProject(projectId);
expect(agents.success).toBe(true);
if (agents.success) {
expect(agents.data.length).toBe(0);
}
});
});
describe('project-agent relationships', () => {
it('assigns agent to project', async () => {
const agentResult = await createAgent({ name: 'Agent', description: '' });
expect(agentResult.success).toBe(true);
if (!agentResult.success) return;
const projectId = generateId();
const assignResult = await assignAgentToProject(agentResult.data.id, projectId);
expect(assignResult.success).toBe(true);
});
it('removes agent from project', async () => {
const agentResult = await createAgent({ name: 'Agent', description: '' });
expect(agentResult.success).toBe(true);
if (!agentResult.success) return;
const projectId = generateId();
await assignAgentToProject(agentResult.data.id, projectId);
const removeResult = await removeAgentFromProject(agentResult.data.id, projectId);
expect(removeResult.success).toBe(true);
const agents = await getAgentsForProject(projectId);
expect(agents.success).toBe(true);
if (agents.success) {
expect(agents.data.length).toBe(0);
}
});
it('gets agents for project', async () => {
const agent1 = await createAgent({ name: 'Agent 1', description: '' });
const agent2 = await createAgent({ name: 'Agent 2', description: '' });
const agent3 = await createAgent({ name: 'Agent 3', description: '' });
expect(agent1.success && agent2.success && agent3.success).toBe(true);
if (!agent1.success || !agent2.success || !agent3.success) return;
const projectId = generateId();
const otherProjectId = generateId();
await assignAgentToProject(agent1.data.id, projectId);
await assignAgentToProject(agent2.data.id, projectId);
await assignAgentToProject(agent3.data.id, otherProjectId);
const result = await getAgentsForProject(projectId);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.length).toBe(2);
const names = result.data.map((a) => a.name).sort();
expect(names).toEqual(['Agent 1', 'Agent 2']);
}
});
it('prevents duplicate assignments', async () => {
const agentResult = await createAgent({ name: 'Agent', description: '' });
expect(agentResult.success).toBe(true);
if (!agentResult.success) return;
const projectId = generateId();
await assignAgentToProject(agentResult.data.id, projectId);
await assignAgentToProject(agentResult.data.id, projectId); // Duplicate
const agents = await getAgentsForProject(projectId);
expect(agents.success).toBe(true);
if (agents.success) {
// Should still be only one assignment
expect(agents.data.length).toBe(1);
}
});
it('returns empty array for project with no agents', async () => {
const projectId = generateId();
const result = await getAgentsForProject(projectId);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([]);
}
});
});
});

View File

@@ -0,0 +1,220 @@
/**
* Agents storage operations
* CRUD operations for agents and project-agent relationships
*/
import { db, generateId, withErrorHandling } from './db.js';
import type { StoredAgent, StoredProjectAgent, StorageResult } from './db.js';
// ============================================================================
// Types
// ============================================================================
/**
* Agent for UI display (with Date objects)
*/
export interface Agent {
id: string;
name: string;
description: string;
promptId: string | null;
enabledToolNames: string[];
preferredModel: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateAgentData {
name: string;
description: string;
promptId?: string | null;
enabledToolNames?: string[];
preferredModel?: string | null;
}
export interface UpdateAgentData {
name?: string;
description?: string;
promptId?: string | null;
enabledToolNames?: string[];
preferredModel?: string | null;
}
// ============================================================================
// Converters
// ============================================================================
function toDomainAgent(stored: StoredAgent): Agent {
return {
id: stored.id,
name: stored.name,
description: stored.description,
promptId: stored.promptId,
enabledToolNames: stored.enabledToolNames,
preferredModel: stored.preferredModel,
createdAt: new Date(stored.createdAt),
updatedAt: new Date(stored.updatedAt)
};
}
// ============================================================================
// Agent CRUD
// ============================================================================
/**
* Get all agents, sorted by name
*/
export async function getAllAgents(): Promise<StorageResult<Agent[]>> {
return withErrorHandling(async () => {
const all = await db.agents.toArray();
const sorted = all.sort((a, b) => a.name.localeCompare(b.name));
return sorted.map(toDomainAgent);
});
}
/**
* Get a single agent by ID
*/
export async function getAgent(id: string): Promise<StorageResult<Agent | null>> {
return withErrorHandling(async () => {
const stored = await db.agents.get(id);
return stored ? toDomainAgent(stored) : null;
});
}
/**
* Create a new agent
*/
export async function createAgent(data: CreateAgentData): Promise<StorageResult<Agent>> {
return withErrorHandling(async () => {
const now = Date.now();
const stored: StoredAgent = {
id: generateId(),
name: data.name,
description: data.description,
promptId: data.promptId ?? null,
enabledToolNames: data.enabledToolNames ?? [],
preferredModel: data.preferredModel ?? null,
createdAt: now,
updatedAt: now
};
await db.agents.add(stored);
return toDomainAgent(stored);
});
}
/**
* Update an existing agent
*/
export async function updateAgent(
id: string,
updates: UpdateAgentData
): Promise<StorageResult<Agent>> {
return withErrorHandling(async () => {
const existing = await db.agents.get(id);
if (!existing) {
throw new Error(`Agent not found: ${id}`);
}
const updated: StoredAgent = {
...existing,
...updates,
updatedAt: Date.now()
};
await db.agents.put(updated);
return toDomainAgent(updated);
});
}
/**
* Delete an agent and all associated project assignments
*/
export async function deleteAgent(id: string): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.transaction('rw', [db.agents, db.projectAgents], async () => {
// Remove all project-agent associations
await db.projectAgents.where('agentId').equals(id).delete();
// Delete the agent itself
await db.agents.delete(id);
});
});
}
// ============================================================================
// Project-Agent Relationships
// ============================================================================
/**
* Assign an agent to a project (adds to roster)
* Idempotent: does nothing if assignment already exists
*/
export async function assignAgentToProject(
agentId: string,
projectId: string
): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
// Check if assignment already exists
const existing = await db.projectAgents
.where('[projectId+agentId]')
.equals([projectId, agentId])
.first();
if (existing) {
return; // Already assigned, do nothing
}
const assignment: StoredProjectAgent = {
id: generateId(),
projectId,
agentId,
createdAt: Date.now()
};
await db.projectAgents.add(assignment);
});
}
/**
* Remove an agent from a project roster
*/
export async function removeAgentFromProject(
agentId: string,
projectId: string
): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.projectAgents
.where('[projectId+agentId]')
.equals([projectId, agentId])
.delete();
});
}
/**
* Get all agents assigned to a project (sorted by name)
*/
export async function getAgentsForProject(projectId: string): Promise<StorageResult<Agent[]>> {
return withErrorHandling(async () => {
const assignments = await db.projectAgents.where('projectId').equals(projectId).toArray();
const agentIds = assignments.map((a) => a.agentId);
if (agentIds.length === 0) {
return [];
}
const agents = await db.agents.where('id').anyOf(agentIds).toArray();
const sorted = agents.sort((a, b) => a.name.localeCompare(b.name));
return sorted.map(toDomainAgent);
});
}
/**
* Get all project IDs that an agent is assigned to
*/
export async function getProjectsForAgent(agentId: string): Promise<StorageResult<string[]>> {
return withErrorHandling(async () => {
const assignments = await db.projectAgents.where('agentId').equals(agentId).toArray();
return assignments.map((a) => a.projectId);
});
}

View File

@@ -23,6 +23,7 @@ function toDomainConversation(stored: StoredConversation): Conversation {
messageCount: stored.messageCount,
systemPromptId: stored.systemPromptId ?? null,
projectId: stored.projectId ?? null,
agentId: stored.agentId ?? null,
summary: stored.summary ?? null,
summaryUpdatedAt: stored.summaryUpdatedAt ? new Date(stored.summaryUpdatedAt) : null
};
@@ -296,6 +297,16 @@ export async function updateSystemPrompt(
return updateConversation(conversationId, { systemPromptId });
}
/**
* Update the agent for a conversation
*/
export async function updateAgentId(
conversationId: string,
agentId: string | null
): Promise<StorageResult<Conversation>> {
return updateConversation(conversationId, { agentId });
}
/**
* Search conversations by title
*/

View File

@@ -23,6 +23,8 @@ export interface StoredConversation {
systemPromptId?: string | null;
/** Optional project ID this conversation belongs to */
projectId?: string | null;
/** Optional agent ID for this conversation (determines prompt and tools) */
agentId?: string | null;
/** Auto-generated conversation summary for cross-chat context */
summary?: string | null;
/** Timestamp when summary was last updated */
@@ -46,6 +48,8 @@ export interface ConversationRecord {
systemPromptId?: string | null;
/** Optional project ID this conversation belongs to */
projectId?: string | null;
/** Optional agent ID for this conversation (determines prompt and tools) */
agentId?: string | null;
/** Auto-generated conversation summary for cross-chat context */
summary?: string | null;
/** Timestamp when summary was last updated */
@@ -266,6 +270,39 @@ export interface StoredChatChunk {
createdAt: number;
}
// ============================================================================
// Agent-related interfaces (v7)
// ============================================================================
/**
* Stored agent configuration
* Agents combine identity, system prompt, and tool subset
*/
export interface StoredAgent {
id: string;
name: string;
description: string;
/** Reference to StoredPrompt.id, null for no specific prompt */
promptId: string | null;
/** Array of tool names this agent can use (subset of available tools) */
enabledToolNames: string[];
/** Optional preferred model for this agent */
preferredModel: string | null;
createdAt: number;
updatedAt: number;
}
/**
* Junction table for project-agent many-to-many relationship
* Defines which agents are available (rostered) for a project
*/
export interface StoredProjectAgent {
id: string;
projectId: string;
agentId: string;
createdAt: number;
}
/**
* Ollama WebUI database class
* Manages all local storage tables
@@ -284,6 +321,9 @@ class OllamaDatabase extends Dexie {
projects!: Table<StoredProject>;
projectLinks!: Table<StoredProjectLink>;
chatChunks!: Table<StoredChatChunk>;
// Agent-related tables (v7)
agents!: Table<StoredAgent>;
projectAgents!: Table<StoredProjectAgent>;
constructor() {
super('vessel');
@@ -374,6 +414,27 @@ class OllamaDatabase extends Dexie {
// Chat message chunks for cross-conversation RAG within projects
chatChunks: 'id, conversationId, projectId, createdAt'
});
// Version 7: Agents for specialized task handling
// Adds: agents table and project-agent junction table for roster assignment
this.version(7).stores({
conversations: 'id, updatedAt, isPinned, isArchived, systemPromptId, projectId',
messages: 'id, conversationId, parentId, createdAt',
attachments: 'id, messageId',
syncQueue: 'id, entityType, createdAt',
documents: 'id, name, createdAt, updatedAt, projectId',
chunks: 'id, documentId',
prompts: 'id, name, isDefault, updatedAt',
modelSystemPrompts: 'modelName',
modelPromptMappings: 'id, modelName, promptId',
projects: 'id, name, createdAt, updatedAt',
projectLinks: 'id, projectId, createdAt',
chatChunks: 'id, conversationId, projectId, createdAt',
// Agents: indexed by id and name for lookup/sorting
agents: 'id, name, createdAt, updatedAt',
// Project-Agent junction table with compound index for efficient queries
projectAgents: 'id, projectId, agentId, [projectId+agentId]'
});
}
}

View File

@@ -11,6 +11,8 @@ export type {
StoredAttachment,
SyncQueueItem,
StoredPrompt,
StoredAgent,
StoredProjectAgent,
StorageResult
} from './db.js';
@@ -27,6 +29,7 @@ export {
archiveConversation,
updateMessageCount,
updateSystemPrompt,
updateAgentId,
searchConversations
} from './conversations.js';
@@ -103,3 +106,17 @@ export {
clearDefaultPrompt,
searchPrompts
} from './prompts.js';
// Agent operations
export {
getAllAgents,
getAgent,
createAgent,
updateAgent,
deleteAgent,
assignAgentToProject,
removeAgentFromProject,
getAgentsForProject,
getProjectsForAgent
} from './agents.js';
export type { Agent, CreateAgentData, UpdateAgentData } from './agents.js';

View File

@@ -175,7 +175,7 @@ export async function updateProject(
*/
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 () => {
await db.transaction('rw', [db.projects, db.projectLinks, db.conversations, db.documents, db.chunks, db.chatChunks], async () => {
// Unlink all conversations from this project
const conversations = await db.conversations.where('projectId').equals(id).toArray();
for (const conv of conversations) {

View File

@@ -0,0 +1,199 @@
/**
* Agents state management using Svelte 5 runes
* Manages agent configurations with IndexedDB persistence
*/
import {
getAllAgents,
getAgent,
createAgent,
updateAgent,
deleteAgent,
assignAgentToProject,
removeAgentFromProject,
getAgentsForProject,
type Agent,
type CreateAgentData,
type UpdateAgentData
} from '$lib/storage';
/** Agents state class with reactive properties */
export class AgentsState {
/** All available agents */
agents = $state<Agent[]>([]);
/** 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;
/** Derived: agents sorted alphabetically by name */
get sortedAgents(): Agent[] {
return [...this.agents].sort((a, b) => a.name.localeCompare(b.name));
}
constructor() {
// Create ready promise
this._readyPromise = new Promise((resolve) => {
this._readyResolve = resolve;
});
// Load agents on initialization (client-side only)
if (typeof window !== 'undefined') {
this.load();
} else {
// SSR: resolve immediately
this._readyResolve?.();
}
}
/** Wait for initial load to complete */
async ready(): Promise<void> {
return this._readyPromise ?? Promise.resolve();
}
/**
* Load all agents from IndexedDB
*/
async load(): Promise<void> {
this.isLoading = true;
this.error = null;
try {
const result = await getAllAgents();
if (result.success) {
this.agents = result.data;
} else {
this.error = result.error;
}
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to load agents';
} finally {
this.isLoading = false;
this._readyResolve?.();
}
}
/**
* Create a new agent
*/
async add(data: CreateAgentData): Promise<Agent | null> {
try {
const result = await createAgent(data);
if (result.success) {
this.agents = [...this.agents, result.data];
return result.data;
} else {
this.error = result.error;
return null;
}
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to create agent';
return null;
}
}
/**
* Update an existing agent
*/
async update(id: string, updates: UpdateAgentData): Promise<boolean> {
try {
const result = await updateAgent(id, updates);
if (result.success) {
this.agents = this.agents.map((a) => (a.id === id ? result.data : a));
return true;
} else {
this.error = result.error;
return false;
}
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to update agent';
return false;
}
}
/**
* Delete an agent
*/
async remove(id: string): Promise<boolean> {
try {
const result = await deleteAgent(id);
if (result.success) {
this.agents = this.agents.filter((a) => a.id !== id);
return true;
} else {
this.error = result.error;
return false;
}
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to delete agent';
return false;
}
}
/**
* Get an agent by ID
*/
get(id: string): Agent | undefined {
return this.agents.find((a) => a.id === id);
}
/**
* Assign an agent to a project
*/
async assignToProject(agentId: string, projectId: string): Promise<boolean> {
try {
const result = await assignAgentToProject(agentId, projectId);
return result.success;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to assign agent to project';
return false;
}
}
/**
* Remove an agent from a project
*/
async removeFromProject(agentId: string, projectId: string): Promise<boolean> {
try {
const result = await removeAgentFromProject(agentId, projectId);
return result.success;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to remove agent from project';
return false;
}
}
/**
* Get all agents assigned to a project
*/
async getForProject(projectId: string): Promise<Agent[]> {
try {
const result = await getAgentsForProject(projectId);
if (result.success) {
return result.data;
} else {
this.error = result.error;
return [];
}
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to get agents for project';
return [];
}
}
/**
* Clear any error state
*/
clearError(): void {
this.error = null;
}
}
/** Singleton agents state instance */
export const agentsState = new AgentsState();

View File

@@ -0,0 +1,280 @@
/**
* AgentsState store tests
*
* Tests the reactive state management for agents.
* Uses fake-indexeddb for in-memory IndexedDB testing.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import 'fake-indexeddb/auto';
import { db, generateId } from '$lib/storage/db.js';
// Import after fake-indexeddb is set up
let AgentsState: typeof import('./agents.svelte.js').AgentsState;
let agentsState: InstanceType<typeof AgentsState>;
describe('AgentsState', () => {
beforeEach(async () => {
// Clear database
await db.agents.clear();
await db.projectAgents.clear();
// Dynamically import to get fresh state
vi.resetModules();
const module = await import('./agents.svelte.js');
AgentsState = module.AgentsState;
agentsState = new AgentsState();
// Wait for initial load to complete
await agentsState.ready();
});
afterEach(async () => {
await db.agents.clear();
await db.projectAgents.clear();
});
describe('initialization', () => {
it('starts with empty agents array', async () => {
expect(agentsState.agents).toEqual([]);
});
it('loads agents on construction in browser', async () => {
// Pre-populate database
await db.agents.add({
id: generateId(),
name: 'Test Agent',
description: 'A test agent',
promptId: null,
enabledToolNames: [],
preferredModel: null,
createdAt: Date.now(),
updatedAt: Date.now()
});
// Create fresh instance
vi.resetModules();
const module = await import('./agents.svelte.js');
const freshState = new module.AgentsState();
await freshState.ready();
expect(freshState.agents.length).toBe(1);
expect(freshState.agents[0].name).toBe('Test Agent');
});
});
describe('sortedAgents', () => {
it('returns agents sorted alphabetically by name', async () => {
await agentsState.add({ name: 'Zeta', description: '' });
await agentsState.add({ name: 'Alpha', description: '' });
await agentsState.add({ name: 'Mid', description: '' });
const sorted = agentsState.sortedAgents;
expect(sorted[0].name).toBe('Alpha');
expect(sorted[1].name).toBe('Mid');
expect(sorted[2].name).toBe('Zeta');
});
});
describe('add', () => {
it('adds agent to state', async () => {
const agent = await agentsState.add({
name: 'New Agent',
description: 'Test description'
});
expect(agent).not.toBeNull();
expect(agentsState.agents.length).toBe(1);
expect(agentsState.agents[0].name).toBe('New Agent');
});
it('persists to storage', async () => {
const agent = await agentsState.add({
name: 'Persistent Agent',
description: ''
});
// Verify in database
const stored = await db.agents.get(agent!.id);
expect(stored).not.toBeUndefined();
expect(stored!.name).toBe('Persistent Agent');
});
it('returns agent with generated id and timestamps', async () => {
const before = Date.now();
const agent = await agentsState.add({
name: 'Agent',
description: ''
});
const after = Date.now();
expect(agent).not.toBeNull();
expect(agent!.id).toBeTruthy();
expect(agent!.createdAt.getTime()).toBeGreaterThanOrEqual(before);
expect(agent!.createdAt.getTime()).toBeLessThanOrEqual(after);
});
});
describe('update', () => {
it('updates agent in state', async () => {
const agent = await agentsState.add({ name: 'Original', description: '' });
expect(agent).not.toBeNull();
const success = await agentsState.update(agent!.id, { name: 'Updated' });
expect(success).toBe(true);
expect(agentsState.agents[0].name).toBe('Updated');
});
it('persists changes', async () => {
const agent = await agentsState.add({ name: 'Original', description: '' });
await agentsState.update(agent!.id, { description: 'New description' });
const stored = await db.agents.get(agent!.id);
expect(stored!.description).toBe('New description');
});
it('updates updatedAt timestamp', async () => {
const agent = await agentsState.add({ name: 'Agent', description: '' });
const originalUpdatedAt = agent!.updatedAt.getTime();
await new Promise((r) => setTimeout(r, 10));
await agentsState.update(agent!.id, { name: 'Changed' });
expect(agentsState.agents[0].updatedAt.getTime()).toBeGreaterThan(originalUpdatedAt);
});
it('returns false for non-existent agent', async () => {
const success = await agentsState.update('non-existent', { name: 'Updated' });
expect(success).toBe(false);
});
});
describe('remove', () => {
it('removes agent from state', async () => {
const agent = await agentsState.add({ name: 'To Delete', description: '' });
expect(agentsState.agents.length).toBe(1);
const success = await agentsState.remove(agent!.id);
expect(success).toBe(true);
expect(agentsState.agents.length).toBe(0);
});
it('removes from storage', async () => {
const agent = await agentsState.add({ name: 'To Delete', description: '' });
await agentsState.remove(agent!.id);
const stored = await db.agents.get(agent!.id);
expect(stored).toBeUndefined();
});
it('removes project-agent associations', async () => {
const agent = await agentsState.add({ name: 'Agent', description: '' });
const projectId = generateId();
await agentsState.assignToProject(agent!.id, projectId);
// Verify assignment exists
let assignments = await db.projectAgents.where('agentId').equals(agent!.id).toArray();
expect(assignments.length).toBe(1);
await agentsState.remove(agent!.id);
// Verify assignment removed
assignments = await db.projectAgents.where('agentId').equals(agent!.id).toArray();
expect(assignments.length).toBe(0);
});
});
describe('get', () => {
it('returns agent by id', async () => {
const agent = await agentsState.add({ name: 'Test', description: 'desc' });
const found = agentsState.get(agent!.id);
expect(found).not.toBeUndefined();
expect(found!.name).toBe('Test');
});
it('returns undefined for missing id', async () => {
const found = agentsState.get('non-existent');
expect(found).toBeUndefined();
});
});
describe('project relationships', () => {
it('gets agents for specific project', async () => {
const agent1 = await agentsState.add({ name: 'Agent 1', description: '' });
const agent2 = await agentsState.add({ name: 'Agent 2', description: '' });
const agent3 = await agentsState.add({ name: 'Agent 3', description: '' });
const projectId = generateId();
const otherProjectId = generateId();
await agentsState.assignToProject(agent1!.id, projectId);
await agentsState.assignToProject(agent2!.id, projectId);
await agentsState.assignToProject(agent3!.id, otherProjectId);
const agents = await agentsState.getForProject(projectId);
expect(agents.length).toBe(2);
const names = agents.map((a) => a.name).sort();
expect(names).toEqual(['Agent 1', 'Agent 2']);
});
it('assigns agent to project', async () => {
const agent = await agentsState.add({ name: 'Agent', description: '' });
const projectId = generateId();
const success = await agentsState.assignToProject(agent!.id, projectId);
expect(success).toBe(true);
const agents = await agentsState.getForProject(projectId);
expect(agents.length).toBe(1);
});
it('removes agent from project', async () => {
const agent = await agentsState.add({ name: 'Agent', description: '' });
const projectId = generateId();
await agentsState.assignToProject(agent!.id, projectId);
const success = await agentsState.removeFromProject(agent!.id, projectId);
expect(success).toBe(true);
const agents = await agentsState.getForProject(projectId);
expect(agents.length).toBe(0);
});
it('returns empty array for project with no agents', async () => {
const agents = await agentsState.getForProject(generateId());
expect(agents).toEqual([]);
});
});
describe('error handling', () => {
it('sets error state on failure', async () => {
// Force an error by updating non-existent agent
await agentsState.update('non-existent', { name: 'Test' });
expect(agentsState.error).not.toBeNull();
expect(agentsState.error).toContain('not found');
});
it('clears error state with clearError', async () => {
await agentsState.update('non-existent', { name: 'Test' });
expect(agentsState.error).not.toBeNull();
agentsState.clearError();
expect(agentsState.error).toBeNull();
});
});
describe('loading state', () => {
it('is false after load completes', async () => {
expect(agentsState.isLoading).toBe(false);
});
});
});

View File

@@ -0,0 +1,301 @@
/**
* Backends state management using Svelte 5 runes
* Manages multiple LLM backend configurations (Ollama, llama.cpp, LM Studio)
*/
/** Backend type identifiers */
export type BackendType = 'ollama' | 'llamacpp' | 'lmstudio';
/** Backend connection status */
export type BackendStatus = 'connected' | 'disconnected' | 'unknown';
/** Backend capabilities */
export interface BackendCapabilities {
canListModels: boolean;
canPullModels: boolean;
canDeleteModels: boolean;
canCreateModels: boolean;
canStreamChat: boolean;
canEmbed: boolean;
}
/** Backend information */
export interface BackendInfo {
type: BackendType;
baseUrl: string;
status: BackendStatus;
capabilities: BackendCapabilities;
version?: string;
error?: string;
}
/** Discovery result for a backend endpoint */
export interface DiscoveryResult {
type: BackendType;
baseUrl: string;
available: boolean;
version?: string;
error?: string;
}
/** Health check result */
export interface HealthResult {
healthy: boolean;
error?: string;
}
/** API response wrapper */
interface ApiResponse<T> {
data?: T;
error?: string;
}
/** Get base URL for API calls */
function getApiBaseUrl(): string {
if (typeof window !== 'undefined') {
const envUrl = (import.meta.env as Record<string, string>)?.PUBLIC_BACKEND_URL;
if (envUrl) return envUrl;
}
return '';
}
/** Make an API request */
async function apiRequest<T>(
method: string,
path: string,
body?: unknown
): Promise<ApiResponse<T>> {
const baseUrl = getApiBaseUrl();
try {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return { error: errorData.error || `HTTP ${response.status}: ${response.statusText}` };
}
const data = await response.json();
return { data };
} catch (err) {
if (err instanceof Error) {
return { error: err.message };
}
return { error: 'Unknown error occurred' };
}
}
/** Backends state class with reactive properties */
export class BackendsState {
/** All configured backends */
backends = $state<BackendInfo[]>([]);
/** Currently active backend type */
activeType = $state<BackendType | null>(null);
/** Loading state */
isLoading = $state(false);
/** Discovering state */
isDiscovering = $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;
/** Derived: the currently active backend info */
get activeBackend(): BackendInfo | null {
if (!this.activeType) return null;
return this.backends.find((b) => b.type === this.activeType) ?? null;
}
/** Derived: whether the active backend can pull models (Ollama only) */
get canPullModels(): boolean {
return this.activeBackend?.capabilities.canPullModels ?? false;
}
/** Derived: whether the active backend can delete models (Ollama only) */
get canDeleteModels(): boolean {
return this.activeBackend?.capabilities.canDeleteModels ?? false;
}
/** Derived: whether the active backend can create custom models (Ollama only) */
get canCreateModels(): boolean {
return this.activeBackend?.capabilities.canCreateModels ?? false;
}
/** Derived: connected backends */
get connectedBackends(): BackendInfo[] {
return this.backends.filter((b) => b.status === 'connected');
}
constructor() {
// Create ready promise
this._readyPromise = new Promise((resolve) => {
this._readyResolve = resolve;
});
// Load backends on initialization (client-side only)
if (typeof window !== 'undefined') {
this.load();
} else {
// SSR: resolve immediately
this._readyResolve?.();
}
}
/** Wait for initial load to complete */
async ready(): Promise<void> {
return this._readyPromise ?? Promise.resolve();
}
/**
* Load backends from the API
*/
async load(): Promise<void> {
this.isLoading = true;
this.error = null;
try {
const result = await apiRequest<{ backends: BackendInfo[]; active: string }>(
'GET',
'/api/v1/ai/backends'
);
if (result.data) {
this.backends = result.data.backends || [];
this.activeType = (result.data.active as BackendType) || null;
} else if (result.error) {
this.error = result.error;
}
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to load backends';
} finally {
this.isLoading = false;
this._readyResolve?.();
}
}
/**
* Discover available backends by probing default endpoints
*/
async discover(endpoints?: Array<{ type: BackendType; baseUrl: string }>): Promise<DiscoveryResult[]> {
this.isDiscovering = true;
this.error = null;
try {
const result = await apiRequest<{ results: DiscoveryResult[] }>(
'POST',
'/api/v1/ai/backends/discover',
endpoints ? { endpoints } : {}
);
if (result.data?.results) {
return result.data.results;
} else if (result.error) {
this.error = result.error;
}
return [];
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to discover backends';
return [];
} finally {
this.isDiscovering = false;
}
}
/**
* Set the active backend
*/
async setActive(type: BackendType): Promise<boolean> {
this.error = null;
try {
const result = await apiRequest<{ active: string }>('POST', '/api/v1/ai/backends/active', {
type
});
if (result.data) {
this.activeType = result.data.active as BackendType;
return true;
} else if (result.error) {
this.error = result.error;
}
return false;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to set active backend';
return false;
}
}
/**
* Check the health of a specific backend
*/
async checkHealth(type: BackendType): Promise<HealthResult> {
try {
const result = await apiRequest<{ status: string; error?: string }>(
'GET',
`/api/v1/ai/backends/${type}/health`
);
if (result.data) {
return {
healthy: result.data.status === 'healthy',
error: result.data.error
};
} else {
return {
healthy: false,
error: result.error
};
}
} catch (err) {
return {
healthy: false,
error: err instanceof Error ? err.message : 'Health check failed'
};
}
}
/**
* Update local backend configuration (URL)
* Note: This updates local state only; backend registration happens via discovery
*/
updateConfig(type: BackendType, config: { baseUrl?: string }): void {
this.backends = this.backends.map((b) => {
if (b.type === type) {
return {
...b,
...config
};
}
return b;
});
}
/**
* Get a backend by type
*/
get(type: BackendType): BackendInfo | undefined {
return this.backends.find((b) => b.type === type);
}
/**
* Clear any error state
*/
clearError(): void {
this.error = null;
}
}
/** Singleton backends state instance */
export const backendsState = new BackendsState();

View File

@@ -0,0 +1,386 @@
/**
* Tests for BackendsState store
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Types for the backends API
interface BackendInfo {
type: 'ollama' | 'llamacpp' | 'lmstudio';
baseUrl: string;
status: 'connected' | 'disconnected' | 'unknown';
capabilities: BackendCapabilities;
version?: string;
error?: string;
}
interface BackendCapabilities {
canListModels: boolean;
canPullModels: boolean;
canDeleteModels: boolean;
canCreateModels: boolean;
canStreamChat: boolean;
canEmbed: boolean;
}
interface DiscoveryResult {
type: 'ollama' | 'llamacpp' | 'lmstudio';
baseUrl: string;
available: boolean;
version?: string;
error?: string;
}
describe('BackendsState', () => {
let BackendsState: typeof import('./backends.svelte.js').BackendsState;
let backendsState: InstanceType<typeof BackendsState>;
beforeEach(async () => {
// Reset modules for fresh state
vi.resetModules();
// Mock fetch globally with default empty response for initial load
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ backends: [], active: '' })
});
// Import fresh module
const module = await import('./backends.svelte.js');
BackendsState = module.BackendsState;
backendsState = new BackendsState();
// Wait for initial load to complete
await backendsState.ready();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initialization', () => {
it('starts with empty backends array', () => {
expect(backendsState.backends).toEqual([]);
});
it('starts with no active backend', () => {
expect(backendsState.activeType).toBeNull();
});
it('starts with not loading', () => {
expect(backendsState.isLoading).toBe(false);
});
it('starts with no error', () => {
expect(backendsState.error).toBeNull();
});
});
describe('load', () => {
it('loads backends from API', async () => {
const mockBackends: BackendInfo[] = [
{
type: 'ollama',
baseUrl: 'http://localhost:11434',
status: 'connected',
capabilities: {
canListModels: true,
canPullModels: true,
canDeleteModels: true,
canCreateModels: true,
canStreamChat: true,
canEmbed: true
},
version: '0.3.0'
}
];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ backends: mockBackends, active: 'ollama' })
});
await backendsState.load();
expect(backendsState.backends).toEqual(mockBackends);
expect(backendsState.activeType).toBe('ollama');
expect(backendsState.isLoading).toBe(false);
});
it('handles load error', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => ({ error: 'Server error' })
});
await backendsState.load();
expect(backendsState.error).not.toBeNull();
expect(backendsState.isLoading).toBe(false);
});
it('handles network error', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error('Network error')
);
await backendsState.load();
expect(backendsState.error).toBe('Network error');
expect(backendsState.isLoading).toBe(false);
});
});
describe('discover', () => {
it('discovers available backends', async () => {
const mockResults: DiscoveryResult[] = [
{
type: 'ollama',
baseUrl: 'http://localhost:11434',
available: true,
version: '0.3.0'
},
{
type: 'llamacpp',
baseUrl: 'http://localhost:8081',
available: true
},
{
type: 'lmstudio',
baseUrl: 'http://localhost:1234',
available: false,
error: 'Connection refused'
}
];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ results: mockResults })
});
const results = await backendsState.discover();
expect(results).toEqual(mockResults);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/ai/backends/discover'),
expect.objectContaining({ method: 'POST' })
);
});
it('returns empty array on error', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error('Network error')
);
const results = await backendsState.discover();
expect(results).toEqual([]);
expect(backendsState.error).toBe('Network error');
});
});
describe('setActive', () => {
it('sets active backend', async () => {
// First load some backends
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
backends: [
{ type: 'ollama', baseUrl: 'http://localhost:11434', status: 'connected' }
],
active: ''
})
});
await backendsState.load();
// Then set active
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ active: 'ollama' })
});
const success = await backendsState.setActive('ollama');
expect(success).toBe(true);
expect(backendsState.activeType).toBe('ollama');
});
it('handles setActive error', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({ error: 'Backend not registered' })
});
const success = await backendsState.setActive('llamacpp');
expect(success).toBe(false);
expect(backendsState.error).not.toBeNull();
});
});
describe('checkHealth', () => {
it('checks backend health', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ status: 'healthy' })
});
const result = await backendsState.checkHealth('ollama');
expect(result.healthy).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/ai/backends/ollama/health'),
expect.any(Object)
);
});
it('returns unhealthy on error response', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
json: async () => ({ status: 'unhealthy', error: 'Connection refused' })
});
const result = await backendsState.checkHealth('ollama');
expect(result.healthy).toBe(false);
expect(result.error).toBe('Connection refused');
});
});
describe('derived state', () => {
it('activeBackend returns the active backend info', async () => {
const mockBackends: BackendInfo[] = [
{
type: 'ollama',
baseUrl: 'http://localhost:11434',
status: 'connected',
capabilities: {
canListModels: true,
canPullModels: true,
canDeleteModels: true,
canCreateModels: true,
canStreamChat: true,
canEmbed: true
}
},
{
type: 'llamacpp',
baseUrl: 'http://localhost:8081',
status: 'connected',
capabilities: {
canListModels: true,
canPullModels: false,
canDeleteModels: false,
canCreateModels: false,
canStreamChat: true,
canEmbed: true
}
}
];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ backends: mockBackends, active: 'llamacpp' })
});
await backendsState.load();
const active = backendsState.activeBackend;
expect(active?.type).toBe('llamacpp');
expect(active?.baseUrl).toBe('http://localhost:8081');
});
it('canPullModels is true only for Ollama', async () => {
const mockBackends: BackendInfo[] = [
{
type: 'ollama',
baseUrl: 'http://localhost:11434',
status: 'connected',
capabilities: {
canListModels: true,
canPullModels: true,
canDeleteModels: true,
canCreateModels: true,
canStreamChat: true,
canEmbed: true
}
}
];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ backends: mockBackends, active: 'ollama' })
});
await backendsState.load();
expect(backendsState.canPullModels).toBe(true);
});
it('canPullModels is false for llama.cpp', async () => {
const mockBackends: BackendInfo[] = [
{
type: 'llamacpp',
baseUrl: 'http://localhost:8081',
status: 'connected',
capabilities: {
canListModels: true,
canPullModels: false,
canDeleteModels: false,
canCreateModels: false,
canStreamChat: true,
canEmbed: true
}
}
];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ backends: mockBackends, active: 'llamacpp' })
});
await backendsState.load();
expect(backendsState.canPullModels).toBe(false);
});
});
describe('updateConfig', () => {
it('updates backend URL', async () => {
// Load initial backends
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
backends: [
{
type: 'ollama',
baseUrl: 'http://localhost:11434',
status: 'connected',
capabilities: {
canListModels: true,
canPullModels: true,
canDeleteModels: true,
canCreateModels: true,
canStreamChat: true,
canEmbed: true
}
}
],
active: 'ollama'
})
});
await backendsState.load();
// Update config
backendsState.updateConfig('ollama', { baseUrl: 'http://192.168.1.100:11434' });
const backend = backendsState.backends.find((b) => b.type === 'ollama');
expect(backend?.baseUrl).toBe('http://192.168.1.100:11434');
});
});
});

View File

@@ -4,6 +4,7 @@
*/
import type { Conversation } from '$lib/types/conversation.js';
import { pinConversation, archiveConversation } from '$lib/storage/conversations.js';
/** Date group labels */
type DateGroup = 'Today' | 'Yesterday' | 'Previous 7 Days' | 'Previous 30 Days' | 'Older';
@@ -161,23 +162,43 @@ export class ConversationsState {
/**
* Toggle pin status of a conversation
* Persists to IndexedDB and queues for backend sync
* @param id The conversation ID
*/
pin(id: string): void {
async pin(id: string): Promise<void> {
const conversation = this.items.find((c) => c.id === id);
if (conversation) {
// Update in-memory state immediately for responsive UI
this.update(id, { isPinned: !conversation.isPinned });
// Persist to IndexedDB and queue for sync
const result = await pinConversation(id);
if (!result.success) {
// Revert on failure
this.update(id, { isPinned: conversation.isPinned });
console.error('Failed to persist pin state:', result.error);
}
}
}
/**
* Toggle archive status of a conversation
* Persists to IndexedDB and queues for backend sync
* @param id The conversation ID
*/
archive(id: string): void {
async archive(id: string): Promise<void> {
const conversation = this.items.find((c) => c.id === id);
if (conversation) {
// Update in-memory state immediately for responsive UI
this.update(id, { isArchived: !conversation.isArchived });
// Persist to IndexedDB and queue for sync
const result = await archiveConversation(id);
if (!result.success) {
// Revert on failure
this.update(id, { isArchived: conversation.isArchived });
console.error('Failed to persist archive state:', result.error);
}
}
}
@@ -205,6 +226,15 @@ export class ConversationsState {
this.update(id, { systemPromptId });
}
/**
* Update the agent for a conversation
* @param id The conversation ID
* @param agentId The agent ID (or null to clear)
*/
setAgentId(id: string, agentId: string | null): void {
this.update(id, { agentId });
}
// ========================================================================
// Project-related methods
// ========================================================================

View File

@@ -13,6 +13,7 @@ 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';
export { AgentsState, agentsState } from './agents.svelte.js';
// Re-export types for convenience
export type { GroupedConversations } from './conversations.svelte.js';

View File

@@ -0,0 +1,131 @@
/**
* Tool definitions for agents - integration tests
*
* Tests getToolDefinitionsForAgent functionality
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
}
};
})();
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
// Import after mocks are set up
let toolsState: typeof import('./tools.svelte.js').toolsState;
describe('getToolDefinitionsForAgent', () => {
beforeEach(async () => {
localStorageMock.clear();
vi.resetModules();
// Set up default tool enabled state (all tools enabled)
localStorageMock.setItem('toolsEnabled', 'true');
localStorageMock.setItem(
'enabledTools',
JSON.stringify({
fetch_url: true,
web_search: true,
calculate: true,
get_location: true,
get_current_time: true
})
);
const module = await import('./tools.svelte.js');
toolsState = module.toolsState;
});
it('returns empty array when toolsEnabled is false', async () => {
toolsState.toolsEnabled = false;
const result = toolsState.getToolDefinitionsForAgent(['fetch_url', 'calculate']);
expect(result).toEqual([]);
});
it('returns only tools matching enabledToolNames', async () => {
const result = toolsState.getToolDefinitionsForAgent(['fetch_url', 'calculate']);
expect(result.length).toBe(2);
const names = result.map((t) => t.function.name).sort();
expect(names).toEqual(['calculate', 'fetch_url']);
});
it('includes both builtin and custom tools', async () => {
// Add a custom tool
toolsState.addCustomTool({
name: 'my_custom_tool',
description: 'A custom tool',
implementation: 'javascript',
code: 'return args;',
parameters: {
type: 'object',
properties: {
input: { type: 'string' }
},
required: ['input']
},
enabled: true
});
const result = toolsState.getToolDefinitionsForAgent([
'fetch_url',
'my_custom_tool'
]);
expect(result.length).toBe(2);
const names = result.map((t) => t.function.name).sort();
expect(names).toEqual(['fetch_url', 'my_custom_tool']);
});
it('returns empty array for empty enabledToolNames', async () => {
const result = toolsState.getToolDefinitionsForAgent([]);
expect(result).toEqual([]);
});
it('ignores tool names that do not exist', async () => {
const result = toolsState.getToolDefinitionsForAgent([
'fetch_url',
'nonexistent_tool',
'calculate'
]);
expect(result.length).toBe(2);
const names = result.map((t) => t.function.name).sort();
expect(names).toEqual(['calculate', 'fetch_url']);
});
it('respects tool enabled state for included tools', async () => {
// Disable calculate tool
toolsState.setToolEnabled('calculate', false);
const result = toolsState.getToolDefinitionsForAgent(['fetch_url', 'calculate']);
// calculate is disabled, so it should not be included
expect(result.length).toBe(1);
expect(result[0].function.name).toBe('fetch_url');
});
it('returns all tools when null is passed (no agent)', async () => {
const withAgent = toolsState.getToolDefinitionsForAgent(['fetch_url']);
const withoutAgent = toolsState.getToolDefinitionsForAgent(null);
expect(withAgent.length).toBe(1);
expect(withoutAgent.length).toBeGreaterThan(1);
});
});

View File

@@ -131,6 +131,57 @@ class ToolsState {
return enabled;
}
/**
* Get tool definitions filtered by an agent's enabled tool names.
* When null is passed, returns all enabled tools (no agent filtering).
*
* @param enabledToolNames - Array of tool names the agent can use, or null for all tools
* @returns Tool definitions that match both the agent's list and are globally enabled
*/
getToolDefinitionsForAgent(enabledToolNames: string[] | null): ToolDefinition[] {
if (!this.toolsEnabled) {
return [];
}
// If null, return all enabled tools (no agent filtering)
if (enabledToolNames === null) {
return this.getEnabledToolDefinitions();
}
// If empty array, return no tools
if (enabledToolNames.length === 0) {
return [];
}
const toolNameSet = new Set(enabledToolNames);
const result: ToolDefinition[] = [];
// Filter builtin tools
const builtinDefs = toolRegistry.getDefinitions();
for (const def of builtinDefs) {
const name = def.function.name;
if (toolNameSet.has(name) && this.isToolEnabled(name)) {
result.push(def);
}
}
// Filter custom tools
for (const custom of this.customTools) {
if (toolNameSet.has(custom.name) && custom.enabled && this.isToolEnabled(custom.name)) {
result.push({
type: 'function',
function: {
name: custom.name,
description: custom.description,
parameters: custom.parameters
}
});
}
}
return result;
}
/**
* Get all tool definitions with their enabled state
*/

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