26 Commits

Author SHA1 Message Date
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
f1e1dc842a chore: bump version to 0.5.0
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-07 20:32:20 +01:00
c2136fc06a feat: add release notes to install script and smart embedding model detection
Install script improvements:
- Show release notes after --update completes
- Detect installed version from backend/cmd/server/main.go
- Fetch releases from GitHub API and display changes between versions
- Graceful fallback when jq not installed (shows link only)

Embedding model detection:
- Add EMBEDDING_MODEL_PATTERNS for detecting embedding models
- Add embeddingModels and hasEmbeddingModel derived properties
- KnowledgeTab shows embedding model status conditionally
- MemoryTab shows model installation status with three states
2026-01-07 20:30:33 +01:00
245526af99 feat: consolidate settings into unified Settings Hub with tabs
- Create Settings Hub with 6 tabs: General, Models, Prompts, Tools, Knowledge, Memory
- Extract page content into reusable tab components:
  - GeneralTab: appearance, chat defaults, shortcuts, about
  - ModelsTab: local models, ollama.com browser, pull/create
  - PromptsTab: my prompts, browse templates
  - ToolsTab: built-in and custom tools with enhanced UI
  - KnowledgeTab: RAG document management
  - MemoryTab: embedding model, auto-compact, model parameters
- Add SettingsTabs navigation component with icons
- Consolidate sidebar from 5 links to single Settings link
- Add 301 redirects for old URLs (/models, /prompts, /tools, /knowledge)
- Upgrade Tools tab with stats bar, search, tool icons, and parameter badges
2026-01-07 20:03:38 +01:00
949802e935 feat: add global search page with semantic search and embedding model settings
- Add dedicated /search page with semantic, titles, and messages tabs
- Add embedding model selector in Settings > Memory Management
- Add background migration service to index existing conversations
- Fix sidebar search to navigate on Enter only (local filtering while typing)
- Fix search page input race condition with isTyping flag
- Update chat-indexer to use configured embedding model
2026-01-07 19:27:08 +01:00
ddce578833 feat: implement cross-chat RAG for project conversations
- Add embedding-based chat indexing for project conversations
- Chunk long messages (1500 chars with 200 overlap) for better coverage
- Index messages when leaving a conversation (background)
- Search indexed chat history with semantic similarity
- Show other project conversations with message count and summary status
- Include relevant chat snippets in project context for LLM
- Fix chunker infinite loop bug near end of text
- Fix curl encoding error with explicit Accept-Encoding header
- Add document previews to project knowledge base context
- Lower RAG threshold to 0.2 and increase topK to 10 for better recall
2026-01-07 18:06:49 +01:00
976b4cd84b fix: wait for projects to load before checking project existence
Fixes race condition where the page would redirect to home before
projectsState.projects was loaded from IndexedDB.
2026-01-07 15:32:04 +01:00
73279c7e60 perf: cache project conversations in derived to avoid repeated method calls 2026-01-07 15:28:48 +01:00
3513215ef5 debug: add console logging to diagnose project page slowdown 2026-01-07 15:26:53 +01:00
5466ef29da fix: remove reference to deleted isUploading state variable 2026-01-07 15:15:04 +01:00
a0d1d4f114 feat: add embedding model selector and non-blocking file upload
- Add embedding model dropdown to project file upload
- Create addDocumentAsync that stores immediately, embeds in background
- Add embeddingStatus field to track pending/processing/ready/failed
- Show status indicator and text for each document
- Upload no longer blocks the UI - files appear immediately
- Background embedding shows toast notifications on completion/error
2026-01-07 15:08:29 +01:00
229c3cc242 fix: add timeout to embedding generation to prevent page freeze
- Add 30 second timeout to generateEmbedding and generateEmbeddings
- Abort controller cancels request if it takes too long
- Clear error message when embedding model isn't available
2026-01-07 15:02:20 +01:00
07d3e07fd6 fix: improve project file upload error handling
- Add null check for projectId before file upload
- Wrap loadProjectData in try-catch after upload
- Show detailed error messages on upload failure
- Fix document filter to use strict equality
2026-01-07 14:59:06 +01:00
298fb9681e feat: add project detail page with new chat creation
- Add /projects/[id] route with project header, stats, and tabbed UI
- Add "New chat in [Project]" input that creates chats inside project
- Add project conversation search and filtering
- Add file upload with drag-and-drop for project documents
- Update ProjectFolder to navigate to project page on click
- Add initialMessage prop to ChatWindow for auto-sending first message
- Support ?firstMessage= query param in chat page for project chats
- Add projectId support to vector-store for document association
2026-01-07 14:53:06 +01:00
5e6994f415 feat: add projects feature for organizing conversations
Add ChatGPT-style projects with cross-chat context sharing:

- Database schema v6 with projects, projectLinks, chatChunks tables
- Project CRUD operations and storage layer
- ProjectsState store with Svelte 5 runes
- Cross-chat context services (summaries, chat indexing, context assembly)
- Project context injection into ChatWindow system prompt
- ProjectFolder collapsible component in sidebar
- ProjectModal for create/edit with Settings, Instructions, Links tabs
- MoveToProjectModal for moving conversations between projects
- "New Project" button in sidebar
- "Move to Project" action on conversation items

Conversations in a project share awareness through:
- Project instructions injected into system prompt
- Summaries of other project conversations
- RAG search across project chat history (stub)
- Reference links
2026-01-07 14:36:12 +01:00
080deb756b fix: improve agentic tool descriptions for better model discovery
Tool descriptions now explicitly state when models should use them:
- Memory Store: 'Use when user asks to remember, recall, list memories'
- Task Manager: 'Use when user mentions tasks, todos, things to do'
- Structured Thinking: 'Use for complex questions requiring analysis'
- Decision Matrix: 'Use when comparing options or recommendations'
- Project Planner: 'Use for planning, roadmaps, breaking work into phases'

This helps smaller models understand WHEN to call these tools instead of
just responding with text claiming they don't have memory/capabilities.
2026-01-07 13:11:00 +01:00
6ec56e3e11 fix: recall without parameters now returns all memories like list
When the model calls recall without key or category (e.g., 'what memories
do you have?'), it now returns all memories across all categories instead
of an error. This provides better UX since models often use recall instead
of list for memory queries.
2026-01-07 13:02:58 +01:00
335c827ac8 fix: improve memory store validation and consistency
- Add validation for missing key/value in store action
- Return formatted entries in recall (category only) - consistent with list
- Check if category exists before reporting success in forget
- Include value in store response for confirmation
- Include entriesRemoved count when forgetting a category
2026-01-07 12:55:20 +01:00
79f9254cf5 fix: memory store list action now returns actual values
The list action was only returning keys and counts, not the actual
stored values. Now it returns full entries with key, value, and
stored timestamp for all memories.
2026-01-07 12:44:07 +01:00
51b89309e6 fix: parse text-based tool calls for models without native function calling
Models like ministral output tool calls as plain text (e.g., tool_name[ARGS]{json})
instead of using Ollama's native tool_calls format. This adds a parser that:

- Detects text-based tool call patterns in model output
- Converts them to OllamaToolCall format for execution
- Cleans the raw tool call text from the message
- Shows proper ToolCallDisplay UI with styled output

Supports three formats:
- tool_name[ARGS]{json}
- <tool_call>{"name": "...", "arguments": {...}}</tool_call>
- {"tool_calls": [...]} JSON blobs
2026-01-07 12:41:40 +01:00
566273415f feat: add agentic tool templates and improve custom tool styling
Some checks failed
Create Release / release (push) Has been cancelled
- Add 5 agentic tool templates: Task Manager, Memory Store,
  Structured Thinking, Decision Matrix, Project Planner
- Task Manager and Memory Store persist to localStorage
- Add pattern-based auto-detect styling for custom tools in chat
- Add credit attribution to prompt browser
- Add 'agentic' category to tool template types
2026-01-07 12:30:00 +01:00
ab5025694f chore: bump version to 0.4.14
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-07 12:06:44 +01:00
7adf5922ba feat: add prompt template browser and design tool templates
- Add curated prompt templates with categories (coding, writing, analysis,
  creative, assistant) that users can browse and add to their library
- Add "Browse Templates" tab to the Prompts page with category filtering
  and preview functionality
- Add Design Brief Generator tool template for creating structured design
  briefs from project requirements
- Add Color Palette Generator tool template for generating harmonious
  color schemes from a base color

Prompts included: Code Reviewer, Refactoring Expert, Debug Assistant,
API Designer, SQL Expert, Technical Writer, Marketing Copywriter,
UI/UX Advisor, Security Auditor, Data Analyst, Creative Brainstormer,
Storyteller, Concise Assistant, Patient Teacher, Devil's Advocate,
Meeting Summarizer
2026-01-07 12:06:30 +01:00
b656859b10 chore: bump version to 0.4.13
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-07 11:38:50 +01:00
d9b009ce0a feat: add test button for HTTP endpoint tools
Adds the ability to test HTTP endpoint custom tools directly in the
editor, matching the existing test functionality for Python and
JavaScript tools. Closes #6.
2026-01-07 11:38:33 +01:00
58 changed files with 9695 additions and 846 deletions

View File

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

View File

@@ -430,7 +430,7 @@ func (f *Fetcher) fetchWithCurl(ctx context.Context, url string, curlPath string
"--max-time", fmt.Sprintf("%d", int(opts.Timeout.Seconds())),
"-A", opts.UserAgent, // User agent
"-w", "\n---CURL_INFO---\n%{content_type}\n%{url_effective}\n%{http_code}", // Output metadata
"--compressed", // Accept compressed responses
"--compressed", // Automatically decompress responses
}
// Add custom headers
@@ -439,9 +439,12 @@ func (f *Fetcher) fetchWithCurl(ctx context.Context, url string, curlPath string
}
// Add common headers for better compatibility
// Override Accept-Encoding to only include widely-supported formats
// This prevents errors when servers return zstd/br that curl may not support
args = append(args,
"-H", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"-H", "Accept-Language: en-US,en;q=0.5",
"-H", "Accept-Encoding: gzip, deflate, identity",
"-H", "DNT: 1",
"-H", "Connection: keep-alive",
"-H", "Upgrade-Insecure-Requests: 1",

View File

@@ -1,6 +1,6 @@
{
"name": "vessel",
"version": "0.4.12",
"version": "0.5.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -23,7 +23,7 @@
formatResultsAsContext,
getKnowledgeBaseStats
} from '$lib/memory';
import { runToolCalls, formatToolResultsForChat, getFunctionModel, USE_FUNCTION_MODEL } from '$lib/tools';
import { runToolCalls, formatToolResultsForChat, getFunctionModel, USE_FUNCTION_MODEL, parseTextToolCalls } from '$lib/tools';
import type { OllamaMessage, OllamaToolCall, OllamaToolDefinition } from '$lib/ollama';
import type { Conversation } from '$lib/types/conversation';
import VirtualMessageList from './VirtualMessageList.svelte';
@@ -36,12 +36,15 @@
import SystemPromptSelector from './SystemPromptSelector.svelte';
import ModelParametersPanel from '$lib/components/settings/ModelParametersPanel.svelte';
import { settingsState } from '$lib/stores/settings.svelte';
import { buildProjectContext, formatProjectContextForPrompt, hasProjectContext } from '$lib/services/project-context.js';
import { updateSummaryOnLeave } from '$lib/services/conversation-summary.js';
/**
* Props interface for ChatWindow
* - mode: 'new' for new chat page, 'conversation' for existing conversations
* - onFirstMessage: callback for when first message is sent in 'new' mode
* - conversation: conversation metadata when in 'conversation' mode
* - initialMessage: auto-send this message when conversation loads (for new project chats)
*/
interface Props {
mode?: 'new' | 'conversation';
@@ -49,13 +52,16 @@
conversation?: Conversation | null;
/** Bindable prop for thinking mode - synced with parent in 'new' mode */
thinkingEnabled?: boolean;
/** Initial message to auto-send when conversation loads */
initialMessage?: string | null;
}
let {
mode = 'new',
onFirstMessage,
conversation,
thinkingEnabled = $bindable(true)
thinkingEnabled = $bindable(true),
initialMessage = null
}: Props = $props();
// Local state for abort controller
@@ -126,6 +132,26 @@
}
});
// Track if initial message has been sent to prevent re-sending
let initialMessageSent = $state(false);
// Auto-send initial message when conversation is ready
$effect(() => {
if (
mode === 'conversation' &&
initialMessage &&
!initialMessageSent &&
chatState.conversationId === conversation?.id &&
!chatState.isStreaming
) {
initialMessageSent = true;
// Small delay to ensure UI is ready
setTimeout(() => {
handleSendMessage(initialMessage);
}, 100);
}
});
/**
* Check if knowledge base has any documents
*/
@@ -140,12 +166,22 @@
/**
* Retrieve relevant context from knowledge base for the query
* @param query - The search query
* @param projectId - If set, search only project docs; if null, search global docs; if undefined, search all
*/
async function retrieveRagContext(query: string): Promise<string | null> {
async function retrieveRagContext(
query: string,
projectId?: string | null
): Promise<string | null> {
if (!ragEnabled || !hasKnowledgeBase) return null;
try {
const results = await searchSimilar(query, 3, 0.5);
// Lower threshold (0.3) to catch more relevant results
const results = await searchSimilar(query, {
topK: 5,
threshold: 0.3,
projectId
});
if (results.length === 0) return null;
const context = formatResultsAsContext(results);
@@ -156,6 +192,27 @@
}
}
/**
* Retrieve project context (instructions, summaries, chat history)
* Only applicable when the conversation belongs to a project
*/
async function retrieveProjectContext(query: string): Promise<string | null> {
const projectId = conversation?.projectId;
const conversationId = chatState.conversationId;
if (!projectId || !conversationId) return null;
try {
const context = await buildProjectContext(projectId, conversationId, query);
if (!hasProjectContext(context)) return null;
return formatProjectContextForPrompt(context);
} catch (error) {
console.error('[ProjectContext] Failed to retrieve context:', error);
return null;
}
}
/**
* Convert OllamaToolCall to the format expected by tool executor
* Ollama doesn't provide IDs, so we generate them
@@ -221,6 +278,36 @@
}
});
// Track previous conversation for summary generation on switch
let previousConversationId: string | null = null;
let previousConversationMessages: typeof chatState.visibleMessages = [];
// Trigger summary generation when leaving a conversation
$effect(() => {
const currentId = conversation?.id || null;
const currentMessages = chatState.visibleMessages;
const currentModel = modelsState.selectedId;
// Store current messages for when we leave
if (currentId) {
previousConversationMessages = [...currentMessages];
}
// When conversation changes, summarize the previous one
if (previousConversationId && previousConversationId !== currentId && currentModel) {
// Need to copy values for the closure
const prevId = previousConversationId;
const prevMessages = previousConversationMessages.map((m) => ({
role: m.message.role,
content: m.message.content
}));
updateSummaryOnLeave(prevId, prevMessages, currentModel);
}
previousConversationId = currentId;
});
/**
* Convert chat state messages to Ollama API format
* Uses messagesForContext to exclude summarized originals but include summaries
@@ -653,10 +740,20 @@
systemParts.push(resolvedPrompt.content);
}
// RAG: Retrieve relevant context for the last user message
// Project context: Retrieve instructions, summaries, and chat history
const lastUserMessage = messages.filter(m => m.role === 'user').pop();
if (lastUserMessage && conversation?.projectId) {
const projectContext = await retrieveProjectContext(lastUserMessage.content);
if (projectContext) {
systemParts.push(projectContext);
}
}
// RAG: Retrieve relevant context for the last user message
// If in a project, search project documents; otherwise search global documents
if (lastUserMessage && ragEnabled && hasKnowledgeBase) {
const ragContext = await retrieveRagContext(lastUserMessage.content);
const ragProjectId = conversation?.projectId ?? null;
const ragContext = await retrieveRagContext(lastUserMessage.content, ragProjectId);
if (ragContext) {
lastRagContext = ragContext;
systemParts.push(`You have access to a knowledge base. Use the following relevant context to help answer the user's question. If the context isn't relevant, you can ignore it.\n\n${ragContext}`);
@@ -742,7 +839,7 @@
streamingMetricsState.endStream();
abortController = null;
// Handle tool calls if received
// Handle native tool calls if received
if (pendingToolCalls && pendingToolCalls.length > 0) {
await executeToolsAndContinue(
model,
@@ -753,13 +850,41 @@
return; // Tool continuation handles persistence
}
// Check for text-based tool calls (models without native tool calling)
const node = chatState.messageTree.get(assistantMessageId);
if (node && toolsState.toolsEnabled) {
const { toolCalls: textToolCalls, cleanContent } = parseTextToolCalls(node.message.content);
if (textToolCalls.length > 0) {
// Convert to OllamaToolCall format
const convertedCalls: OllamaToolCall[] = textToolCalls.map(tc => ({
function: {
name: tc.name,
arguments: tc.arguments
}
}));
// Update message content to remove the raw tool call text
if (cleanContent !== node.message.content) {
node.message.content = cleanContent || 'Using tool...';
}
await executeToolsAndContinue(
model,
assistantMessageId,
convertedCalls,
conversationId
);
return; // Tool continuation handles persistence
}
}
// Persist assistant message to IndexedDB with the SAME ID as chatState
if (conversationId) {
const node = chatState.messageTree.get(assistantMessageId);
if (node) {
const nodeForPersist = chatState.messageTree.get(assistantMessageId);
if (nodeForPersist) {
await addStoredMessage(
conversationId,
{ role: 'assistant', content: node.message.content },
{ role: 'assistant', content: nodeForPersist.message.content },
parentMessageId,
assistantMessageId
);
@@ -964,7 +1089,7 @@
streamingMetricsState.endStream();
abortController = null;
// Handle tool calls if received
// Handle native tool calls if received
if (pendingToolCalls && pendingToolCalls.length > 0) {
await executeToolsAndContinue(
selectedModel,
@@ -975,13 +1100,41 @@
return;
}
// Check for text-based tool calls (models without native tool calling)
const node = chatState.messageTree.get(newMessageId);
if (node && toolsState.toolsEnabled) {
const { toolCalls: textToolCalls, cleanContent } = parseTextToolCalls(node.message.content);
if (textToolCalls.length > 0) {
// Convert to OllamaToolCall format
const convertedCalls: OllamaToolCall[] = textToolCalls.map(tc => ({
function: {
name: tc.name,
arguments: tc.arguments
}
}));
// Update message content to remove the raw tool call text
if (cleanContent !== node.message.content) {
node.message.content = cleanContent || 'Using tool...';
}
await executeToolsAndContinue(
selectedModel,
newMessageId,
convertedCalls,
conversationId
);
return;
}
}
// Persist regenerated assistant message to IndexedDB with the SAME ID
if (conversationId && parentUserMessageId) {
const node = chatState.messageTree.get(newMessageId);
if (node) {
const nodeForPersist = chatState.messageTree.get(newMessageId);
if (nodeForPersist) {
await addStoredMessage(
conversationId,
{ role: 'assistant', content: node.message.content },
{ role: 'assistant', content: nodeForPersist.message.content },
parentUserMessageId,
newMessageId
);

View File

@@ -12,8 +12,8 @@
let { toolCalls }: Props = $props();
// Tool metadata for icons and colors
const toolMeta: Record<string, { icon: string; color: string; label: string }> = {
// Tool metadata for built-in tools (exact matches)
const builtinToolMeta: Record<string, { icon: string; color: string; label: string }> = {
get_location: {
icon: '📍',
color: 'from-rose-500 to-pink-600',
@@ -41,12 +41,103 @@
}
};
// Pattern-based styling for custom tools (checked in order, first match wins)
const toolPatterns: Array<{ patterns: string[]; icon: string; color: string; label: string }> = [
// Agentic Tools (check first for specific naming)
{ patterns: ['task_manager', 'task-manager', 'taskmanager'], icon: '📋', color: 'from-indigo-500 to-purple-600', label: 'Tasks' },
{ patterns: ['memory_store', 'memory-store', 'memorystore', 'scratchpad'], icon: '🧠', color: 'from-violet-500 to-purple-600', label: 'Memory' },
{ patterns: ['think_step', 'structured_thinking', 'reasoning'], icon: '💭', color: 'from-cyan-500 to-blue-600', label: 'Thinking' },
{ patterns: ['decision_matrix', 'decision-matrix', 'evaluate'], icon: '⚖️', color: 'from-amber-500 to-orange-600', label: 'Decision' },
{ patterns: ['project_planner', 'project-planner', 'breakdown'], icon: '📊', color: 'from-emerald-500 to-teal-600', label: 'Planning' },
// Design & UI
{ patterns: ['design', 'brief', 'ui', 'ux', 'layout', 'wireframe'], icon: '🎨', color: 'from-pink-500 to-rose-600', label: 'Design' },
{ patterns: ['color', 'palette', 'theme', 'style'], icon: '🎨', color: 'from-fuchsia-500 to-pink-600', label: 'Color' },
// Search & Discovery
{ patterns: ['search', 'find', 'lookup', 'query'], icon: '🔍', color: 'from-blue-500 to-cyan-600', label: 'Search' },
// Web & API
{ patterns: ['fetch', 'http', 'api', 'request', 'webhook'], icon: '🌐', color: 'from-violet-500 to-purple-600', label: 'API' },
{ patterns: ['url', 'link', 'web', 'scrape'], icon: '🔗', color: 'from-indigo-500 to-violet-600', label: 'Web' },
// Data & Analysis
{ patterns: ['data', 'analyze', 'stats', 'chart', 'graph', 'metric'], icon: '📊', color: 'from-cyan-500 to-blue-600', label: 'Analysis' },
{ patterns: ['json', 'transform', 'parse', 'convert', 'format'], icon: '🔄', color: 'from-sky-500 to-cyan-600', label: 'Transform' },
// Math & Calculation
{ patterns: ['calc', 'math', 'compute', 'formula', 'number'], icon: '🧮', color: 'from-emerald-500 to-teal-600', label: 'Calculate' },
// Time & Date
{ patterns: ['time', 'date', 'clock', 'schedule', 'calendar'], icon: '🕐', color: 'from-amber-500 to-orange-600', label: 'Time' },
// Location & Maps
{ patterns: ['location', 'geo', 'place', 'address', 'map', 'coord'], icon: '📍', color: 'from-rose-500 to-pink-600', label: 'Location' },
// Text & String
{ patterns: ['text', 'string', 'word', 'sentence', 'paragraph'], icon: '📝', color: 'from-slate-500 to-gray-600', label: 'Text' },
// Files & Storage
{ patterns: ['file', 'read', 'write', 'save', 'load', 'export', 'import'], icon: '📁', color: 'from-yellow-500 to-amber-600', label: 'File' },
// Communication
{ patterns: ['email', 'mail', 'send', 'message', 'notify', 'alert'], icon: '📧', color: 'from-red-500 to-rose-600', label: 'Message' },
// User & Auth
{ patterns: ['user', 'auth', 'login', 'account', 'profile', 'session'], icon: '👤', color: 'from-blue-500 to-indigo-600', label: 'User' },
// Database
{ patterns: ['database', 'db', 'sql', 'table', 'record', 'store'], icon: '🗄️', color: 'from-orange-500 to-red-600', label: 'Database' },
// Code & Execution
{ patterns: ['code', 'script', 'execute', 'run', 'shell', 'command'], icon: '💻', color: 'from-green-500 to-emerald-600', label: 'Code' },
// Images & Media
{ patterns: ['image', 'photo', 'picture', 'screenshot', 'media', 'video'], icon: '🖼️', color: 'from-purple-500 to-fuchsia-600', label: 'Media' },
// Weather
{ patterns: ['weather', 'forecast', 'temperature', 'climate'], icon: '🌤️', color: 'from-sky-400 to-blue-500', label: 'Weather' },
// Translation & Language
{ patterns: ['translate', 'language', 'i18n', 'locale'], icon: '🌍', color: 'from-teal-500 to-cyan-600', label: 'Translate' },
// Security & Encryption
{ patterns: ['encrypt', 'decrypt', 'hash', 'encode', 'decode', 'secure', 'password'], icon: '🔐', color: 'from-red-600 to-orange-600', label: 'Security' },
// Random & Generation
{ patterns: ['random', 'generate', 'uuid', 'create', 'make'], icon: '🎲', color: 'from-violet-500 to-purple-600', label: 'Generate' },
// Lists & Collections
{ patterns: ['list', 'array', 'collection', 'filter', 'sort'], icon: '📋', color: 'from-blue-400 to-indigo-500', label: 'List' },
// Validation & Check
{ patterns: ['valid', 'check', 'verify', 'test', 'assert'], icon: '✅', color: 'from-green-500 to-teal-600', label: 'Validate' }
];
const defaultMeta = {
icon: '⚙️',
color: 'from-gray-500 to-gray-600',
color: 'from-slate-500 to-slate-600',
label: 'Tool'
};
/**
* Get tool metadata - checks builtin tools first, then pattern matches, then default
*/
function getToolMeta(toolName: string): { icon: string; color: string; label: string } {
// Check builtin tools first (exact match)
if (builtinToolMeta[toolName]) {
return builtinToolMeta[toolName];
}
// Pattern match for custom tools
const lowerName = toolName.toLowerCase();
for (const pattern of toolPatterns) {
if (pattern.patterns.some((p) => lowerName.includes(p))) {
return pattern;
}
}
// Default fallback
return defaultMeta;
}
/**
* Convert tool name to human-readable label
*/
function formatToolLabel(toolName: string, detectedLabel: string): string {
// If it's a known builtin or detected pattern, use that label
if (detectedLabel !== 'Tool') {
return detectedLabel;
}
// Otherwise, humanize the tool name
return toolName
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
/**
* Parse arguments to display-friendly format
*/
@@ -200,7 +291,8 @@
<div class="my-3 space-y-2">
{#each toolCalls as call (call.id)}
{@const meta = toolMeta[call.name] || defaultMeta}
{@const meta = getToolMeta(call.name)}
{@const displayLabel = formatToolLabel(call.name, meta.label)}
{@const args = parseArgs(call.arguments)}
{@const argEntries = Object.entries(args).filter(([_, v]) => v !== undefined && v !== null)}
{@const isExpanded = expandedCalls.has(call.id)}
@@ -216,12 +308,12 @@
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-100/50 dark:hover:bg-slate-700/50"
>
<!-- Icon -->
<span class="text-xl" role="img" aria-label={meta.label}>{meta.icon}</span>
<span class="text-xl" role="img" aria-label={displayLabel}>{meta.icon}</span>
<!-- Tool name and summary -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-slate-800 dark:text-slate-100">{meta.label}</span>
<span class="font-medium text-slate-800 dark:text-slate-100">{displayLabel}</span>
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">{call.name}</span>
</div>

View File

@@ -1,13 +1,14 @@
<script lang="ts">
/**
* ConversationItem.svelte - Single conversation row in the sidebar
* Shows title, model, and hover actions (pin, export, delete)
* Shows title, model, and hover actions (pin, move, export, delete)
*/
import type { Conversation } from '$lib/types/conversation.js';
import { goto } from '$app/navigation';
import { conversationsState, uiState, chatState, toastState } from '$lib/stores';
import { deleteConversation } from '$lib/storage';
import { ExportDialog } from '$lib/components/shared';
import MoveToProjectModal from '$lib/components/projects/MoveToProjectModal.svelte';
interface Props {
conversation: Conversation;
@@ -19,6 +20,9 @@
// Export dialog state
let showExportDialog = $state(false);
// Move to project dialog state
let showMoveDialog = $state(false);
/** Format relative time for display */
function formatRelativeTime(date: Date): string {
const now = new Date();
@@ -48,6 +52,13 @@
showExportDialog = true;
}
/** Handle move to project */
function handleMove(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
showMoveDialog = true;
}
/** Handle delete */
async function handleDelete(e: MouseEvent) {
e.preventDefault();
@@ -174,6 +185,30 @@
{/if}
</button>
<!-- Move to project button -->
<button
type="button"
onclick={handleMove}
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Move to project"
title="Move to project"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
/>
</svg>
</button>
<!-- Export button -->
<button
type="button"
@@ -230,3 +265,10 @@
isOpen={showExportDialog}
onClose={() => (showExportDialog = false)}
/>
<!-- Move to Project Modal -->
<MoveToProjectModal
conversationId={conversation.id}
isOpen={showMoveDialog}
onClose={() => (showMoveDialog = false)}
/>

View File

@@ -1,17 +1,44 @@
<script lang="ts">
/**
* ConversationList.svelte - Chat history list grouped by date
* Uses local conversationsState for immediate updates (offline-first)
* ConversationList.svelte - Chat history list with projects and date groups
* Shows projects as folders at the top, then ungrouped conversations by date
*/
import { conversationsState, chatState } from '$lib/stores';
import { conversationsState, chatState, projectsState } from '$lib/stores';
import ConversationItem from './ConversationItem.svelte';
import ProjectFolder from './ProjectFolder.svelte';
import type { Conversation } from '$lib/types/conversation.js';
interface Props {
onEditProject?: (projectId: string) => void;
}
let { onEditProject }: Props = $props();
// State for showing archived conversations
let showArchived = $state(false);
// Derived: Conversations without a project, grouped by date
const ungroupedConversations = $derived.by(() => {
return conversationsState.withoutProject();
});
// Derived: Check if there are any project folders or ungrouped conversations
const hasAnyContent = $derived.by(() => {
return projectsState.projects.length > 0 || ungroupedConversations.length > 0;
});
// Derived: Map of project ID to conversations (cached to avoid repeated calls)
const projectConversationsMap = $derived.by(() => {
const map = new Map<string, Conversation[]>();
for (const project of projectsState.projects) {
map.set(project.id, conversationsState.forProject(project.id));
}
return map;
});
</script>
<div class="flex flex-col px-2 py-1">
{#if conversationsState.grouped.length === 0}
{#if !hasAnyContent && conversationsState.grouped.length === 0}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center px-4 py-8 text-center">
<svg
@@ -43,24 +70,45 @@
{/if}
</div>
{:else}
<!-- Grouped conversations -->
{#each conversationsState.grouped as { group, conversations } (group)}
<div class="mb-2">
<!-- Group header -->
<!-- Projects section -->
{#if projectsState.sortedProjects.length > 0}
<div class="mb-3">
<h3 class="sticky top-0 z-10 bg-theme-primary px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-theme-muted">
{group}
Projects
</h3>
<!-- Conversations in this group -->
<div class="flex flex-col gap-0.5">
{#each conversations as conversation (conversation.id)}
<ConversationItem
{conversation}
isSelected={chatState.conversationId === conversation.id}
{#each projectsState.sortedProjects as project (project.id)}
<ProjectFolder
{project}
conversations={projectConversationsMap.get(project.id) ?? []}
{onEditProject}
/>
{/each}
</div>
</div>
{/if}
<!-- Ungrouped conversations (by date) -->
{#each conversationsState.grouped as { group, conversations } (group)}
{@const ungroupedInGroup = conversations.filter(c => !c.projectId)}
{#if ungroupedInGroup.length > 0}
<div class="mb-2">
<!-- Group header -->
<h3 class="sticky top-0 z-10 bg-theme-primary px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-theme-muted">
{group}
</h3>
<!-- Conversations in this group (without project) -->
<div class="flex flex-col gap-0.5">
{#each ungroupedInGroup as conversation (conversation.id)}
<ConversationItem
{conversation}
isSelected={chatState.conversationId === conversation.id}
/>
{/each}
</div>
</div>
{/if}
{/each}
<!-- Archived section -->

View File

@@ -0,0 +1,143 @@
<script lang="ts">
/**
* ProjectFolder.svelte - Collapsible folder for project conversations
* Shows project name, color indicator, and nested conversations
*/
import type { Project } from '$lib/stores/projects.svelte.js';
import type { Conversation } from '$lib/types/conversation.js';
import { projectsState, chatState } from '$lib/stores';
import ConversationItem from './ConversationItem.svelte';
import { goto } from '$app/navigation';
interface Props {
project: Project;
conversations: Conversation[];
onEditProject?: (projectId: string) => void;
}
let { project, conversations, onEditProject }: Props = $props();
// Track if this project is expanded
const isExpanded = $derived(!projectsState.collapsedIds.has(project.id));
/** Toggle folder collapse state */
async function handleToggle(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
await projectsState.toggleCollapse(project.id);
}
/** Navigate to project page */
function handleOpenProject(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
goto(`/projects/${project.id}`);
}
/** Handle project settings click */
function handleSettings(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
onEditProject?.(project.id);
}
</script>
<div class="mb-1">
<!-- Project header -->
<div class="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-theme-secondary/60">
<!-- Collapse indicator (clickable) -->
<button
type="button"
onclick={handleToggle}
class="shrink-0 rounded p-0.5 text-theme-muted transition-colors hover:text-theme-primary"
aria-label={isExpanded ? 'Collapse project' : 'Expand project'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3 transition-transform {isExpanded ? 'rotate-90' : ''}"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- Project link (folder icon + name) - navigates to project page -->
<a
href="/projects/{project.id}"
onclick={handleOpenProject}
class="flex flex-1 items-center gap-2 truncate"
title="Open project"
>
<!-- Folder icon with project color -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 shrink-0"
viewBox="0 0 20 20"
fill={project.color || '#10b981'}
>
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</svg>
<!-- Project name -->
<span class="flex-1 truncate text-sm font-medium text-theme-secondary hover:text-theme-primary">
{project.name}
</span>
</a>
<!-- Conversation count -->
<span class="shrink-0 text-xs text-theme-muted">
{conversations.length}
</span>
<!-- Settings button (hidden until hover) -->
<button
type="button"
onclick={handleSettings}
class="shrink-0 rounded p-0.5 text-theme-muted opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-theme-primary group-hover:opacity-100"
aria-label="Project settings"
title="Settings"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
<!-- Conversations in this project -->
{#if isExpanded && conversations.length > 0}
<div class="ml-3 flex flex-col gap-0.5 border-l border-theme/30 pl-2">
{#each conversations as conversation (conversation.id)}
<ConversationItem
{conversation}
isSelected={chatState.conversationId === conversation.id}
/>
{/each}
</div>
{/if}
<!-- Empty state for expanded folder with no conversations -->
{#if isExpanded && conversations.length === 0}
<div class="ml-3 border-l border-theme/30 pl-2">
<p class="px-3 py-2 text-xs text-theme-muted italic">
No conversations yet
</p>
</div>
{/if}
</div>

View File

@@ -1,16 +1,33 @@
<script lang="ts">
/**
* Sidenav.svelte - Collapsible sidebar for the Ollama chat UI
* Contains navigation header, search, and conversation list
* Contains navigation header, search, projects, and conversation list
*/
import { page } from '$app/stores';
import { uiState } from '$lib/stores';
import SidenavHeader from './SidenavHeader.svelte';
import SidenavSearch from './SidenavSearch.svelte';
import ConversationList from './ConversationList.svelte';
import ProjectModal from '$lib/components/projects/ProjectModal.svelte';
// Check if a path is active
const isActive = (path: string) => $page.url.pathname === path;
// Project modal state
let showProjectModal = $state(false);
let editingProjectId = $state<string | null>(null);
function handleCreateProject() {
editingProjectId = null;
showProjectModal = true;
}
function handleEditProject(projectId: string) {
editingProjectId = projectId;
showProjectModal = true;
}
function handleCloseProjectModal() {
showProjectModal = false;
editingProjectId = null;
}
</script>
<!-- Overlay for mobile (closes sidenav when clicking outside) -->
@@ -38,105 +55,41 @@
<!-- Search bar -->
<SidenavSearch />
<!-- Conversation list (scrollable) -->
<div class="flex-1 overflow-y-auto overflow-x-hidden">
<ConversationList />
<!-- Create Project button -->
<div class="px-3 pb-2">
<button
type="button"
onclick={handleCreateProject}
class="flex w-full items-center gap-2 rounded-lg border border-dashed border-theme px-3 py-2 text-sm text-theme-muted transition-colors hover:border-emerald-500/50 hover:bg-theme-secondary/50 hover:text-emerald-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
<span>New Project</span>
</button>
</div>
<!-- Footer / Navigation links -->
<div class="border-t border-theme p-3 space-y-1">
<!-- Model Browser link -->
<a
href="/models"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/models') ? 'bg-cyan-500/20 text-cyan-600 dark:bg-cyan-900/30 dark:text-cyan-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 0-6.23.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
/>
</svg>
<span>Models</span>
</a>
<!-- Conversation list (scrollable) -->
<div class="flex-1 overflow-y-auto overflow-x-hidden">
<ConversationList onEditProject={handleEditProject} />
</div>
<!-- Knowledge Base link -->
<a
href="/knowledge"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/knowledge') ? 'bg-blue-500/20 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"
/>
</svg>
<span>Knowledge Base</span>
</a>
<!-- Tools link -->
<a
href="/tools"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/tools') ? 'bg-emerald-500/20 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z"
/>
</svg>
<span>Tools</span>
</a>
<!-- Prompts link -->
<a
href="/prompts"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/prompts') ? 'bg-purple-500/20 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
<span>Prompts</span>
</a>
<!-- Settings link -->
<!-- Footer / Settings link -->
<div class="border-t border-theme p-3">
<a
href="/settings"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/settings') ? 'bg-gray-500/20 text-gray-600 dark:bg-gray-700/30 dark:text-gray-300' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {$page.url.pathname.startsWith('/settings') ? 'bg-violet-500/20 text-violet-600 dark:bg-violet-900/30 dark:text-violet-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -158,3 +111,10 @@
</div>
</div>
</aside>
<!-- Project Modal -->
<ProjectModal
isOpen={showProjectModal}
onClose={handleCloseProjectModal}
projectId={editingProjectId}
/>

View File

@@ -1,20 +1,31 @@
<script lang="ts">
/**
* SidenavSearch.svelte - Search input for filtering conversations
* Uses local conversationsState for instant client-side filtering
* SidenavSearch.svelte - Search input that navigates to search page
*/
import { goto } from '$app/navigation';
import { conversationsState } from '$lib/stores';
// Handle input change - directly updates store for instant filtering
let searchValue = $state('');
// Handle input change - only filter locally, navigate on Enter
function handleInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
conversationsState.searchQuery = value;
searchValue = value;
conversationsState.searchQuery = value; // Local filtering in sidebar
}
// Handle clear button
function handleClear() {
searchValue = '';
conversationsState.clearSearch();
}
// Handle Enter key to navigate to search page
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && searchValue.trim()) {
goto(`/search?query=${encodeURIComponent(searchValue)}`);
}
}
</script>
<div class="px-3 pb-2">
@@ -38,15 +49,16 @@
<!-- Search input -->
<input
type="text"
value={conversationsState.searchQuery}
bind:value={searchValue}
oninput={handleInput}
onkeydown={handleKeydown}
placeholder="Search conversations..."
data-search-input
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary/50 py-2 pl-10 pr-9 text-sm text-theme-primary placeholder-theme-placeholder transition-colors focus:border-violet-500/50 focus:bg-theme-tertiary focus:outline-none focus:ring-1 focus:ring-violet-500/50"
class="w-full rounded-lg border border-theme bg-slate-800 py-2 pl-10 pr-9 text-sm text-white placeholder-slate-400 transition-colors focus:border-violet-500/50 focus:bg-slate-700 focus:outline-none focus:ring-1 focus:ring-violet-500/50"
/>
<!-- Clear button (visible when there's text) -->
{#if conversationsState.searchQuery}
{#if searchValue}
<button
type="button"
onclick={handleClear}

View File

@@ -0,0 +1,176 @@
<script lang="ts">
/**
* MoveToProjectModal - Move a conversation to a different project
*/
import { projectsState, conversationsState, toastState } from '$lib/stores';
import { moveConversationToProject } from '$lib/storage/conversations.js';
interface Props {
isOpen: boolean;
onClose: () => void;
conversationId: string;
}
let { isOpen, onClose, conversationId }: Props = $props();
let isLoading = $state(false);
// Get current conversation's project
const currentConversation = $derived.by(() => {
return conversationsState.find(conversationId);
});
const currentProjectId = $derived(currentConversation?.projectId || null);
async function handleSelect(projectId: string | null) {
if (projectId === currentProjectId) {
onClose();
return;
}
isLoading = true;
try {
const result = await moveConversationToProject(conversationId, projectId);
if (result.success) {
// Update local state
conversationsState.moveToProject(conversationId, projectId);
const projectName = projectId
? projectsState.projects.find(p => p.id === projectId)?.name || 'project'
: 'No Project';
toastState.success(`Moved to ${projectName}`);
onClose();
} else {
toastState.error('Failed to move conversation');
}
} catch {
toastState.error('Failed to move conversation');
} finally {
isLoading = false;
}
}
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="move-dialog-title"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-sm rounded-xl border border-theme bg-theme-primary shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<h2 id="move-dialog-title" class="text-lg font-semibold text-theme-primary">
Move to Project
</h2>
<button
type="button"
onclick={onClose}
class="rounded-lg p-1.5 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
aria-label="Close dialog"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="max-h-[50vh] overflow-y-auto px-2 py-3">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent"></div>
</div>
{:else}
<!-- No Project option -->
<button
type="button"
onclick={() => handleSelect(null)}
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-theme-secondary {currentProjectId === null ? 'bg-theme-secondary' : ''}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-theme-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
/>
</svg>
<span class="text-sm text-theme-secondary">No Project</span>
{#if currentProjectId === null}
<svg xmlns="http://www.w3.org/2000/svg" class="ml-auto h-5 w-5 text-emerald-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
</svg>
{/if}
</button>
<!-- Project options -->
{#if projectsState.sortedProjects.length > 0}
<div class="my-2 border-t border-theme"></div>
{#each projectsState.sortedProjects as project (project.id)}
<button
type="button"
onclick={() => handleSelect(project.id)}
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-theme-secondary {currentProjectId === project.id ? 'bg-theme-secondary' : ''}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
viewBox="0 0 20 20"
fill={project.color || '#10b981'}
>
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</svg>
<span class="truncate text-sm text-theme-secondary">{project.name}</span>
{#if currentProjectId === project.id}
<svg xmlns="http://www.w3.org/2000/svg" class="ml-auto h-5 w-5 shrink-0 text-emerald-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
</svg>
{/if}
</button>
{/each}
{/if}
<!-- Empty state -->
{#if projectsState.sortedProjects.length === 0}
<p class="px-4 py-6 text-center text-sm text-theme-muted">
No projects yet. Create one from the sidebar.
</p>
{/if}
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,473 @@
<script lang="ts">
/**
* ProjectModal - Create/Edit project with tabs for settings, instructions, and links
*/
import { projectsState, toastState } from '$lib/stores';
import type { Project } from '$lib/stores/projects.svelte.js';
import { addProjectLink, deleteProjectLink, getProjectLinks, type ProjectLink } from '$lib/storage/projects.js';
import { ConfirmDialog } from '$lib/components/shared';
interface Props {
isOpen: boolean;
onClose: () => void;
projectId?: string | null;
onUpdate?: () => void; // Called when project data changes (links added/deleted, etc.)
}
let { isOpen, onClose, projectId = null, onUpdate }: Props = $props();
// Form state
let name = $state('');
let description = $state('');
let instructions = $state('');
let color = $state('#10b981');
let links = $state<ProjectLink[]>([]);
let newLinkUrl = $state('');
let newLinkTitle = $state('');
let newLinkDescription = $state('');
let isLoading = $state(false);
let activeTab = $state<'settings' | 'instructions' | 'links'>('settings');
let showDeleteConfirm = $state(false);
// Predefined colors for quick selection
const presetColors = [
'#10b981', // emerald
'#3b82f6', // blue
'#8b5cf6', // violet
'#f59e0b', // amber
'#ef4444', // red
'#ec4899', // pink
'#06b6d4', // cyan
'#84cc16', // lime
];
// Get existing project data when editing
const existingProject = $derived.by(() => {
if (!projectId) return null;
return projectsState.projects.find(p => p.id === projectId) || null;
});
// Modal title
const modalTitle = $derived(projectId ? 'Edit Project' : 'Create Project');
// Reset form when modal opens/closes or project changes
$effect(() => {
if (isOpen) {
if (existingProject) {
name = existingProject.name;
description = existingProject.description || '';
instructions = existingProject.instructions || '';
color = existingProject.color || '#10b981';
loadProjectLinks();
} else {
name = '';
description = '';
instructions = '';
color = '#10b981';
links = [];
}
activeTab = 'settings';
}
});
async function loadProjectLinks() {
if (!projectId) return;
const result = await getProjectLinks(projectId);
if (result.success) {
links = result.data;
}
}
async function handleSave() {
if (!name.trim()) {
toastState.error('Project name is required');
return;
}
isLoading = true;
try {
if (projectId) {
// Update existing project
const success = await projectsState.update(projectId, {
name: name.trim(),
description: description.trim(),
instructions: instructions.trim(),
color
});
if (success) {
toastState.success('Project updated');
onClose();
} else {
toastState.error('Failed to update project');
}
} else {
// Create new project
const project = await projectsState.add({
name: name.trim(),
description: description.trim(),
instructions: instructions.trim(),
color
});
if (project) {
toastState.success('Project created');
onClose();
} else {
toastState.error('Failed to create project');
}
}
} finally {
isLoading = false;
}
}
function handleDeleteClick() {
if (!projectId) return;
showDeleteConfirm = true;
}
async function handleDeleteConfirm() {
if (!projectId) return;
showDeleteConfirm = false;
isLoading = true;
try {
const success = await projectsState.remove(projectId);
if (success) {
toastState.success('Project deleted');
onClose();
} else {
toastState.error('Failed to delete project');
}
} finally {
isLoading = false;
}
}
async function handleAddLink() {
if (!projectId || !newLinkUrl.trim()) {
toastState.error('URL is required');
return;
}
try {
const result = await addProjectLink({
projectId,
url: newLinkUrl.trim(),
title: newLinkTitle.trim() || newLinkUrl.trim(),
description: newLinkDescription.trim()
});
if (result.success) {
links = [...links, result.data];
newLinkUrl = '';
newLinkTitle = '';
newLinkDescription = '';
toastState.success('Link added');
onUpdate?.(); // Notify parent to refresh
} else {
toastState.error('Failed to add link');
}
} catch {
toastState.error('Failed to add link');
}
}
async function handleDeleteLink(linkId: string) {
try {
const result = await deleteProjectLink(linkId);
if (result.success) {
links = links.filter(l => l.id !== linkId);
toastState.success('Link removed');
onUpdate?.(); // Notify parent to refresh
} else {
toastState.error('Failed to remove link');
}
} catch {
toastState.error('Failed to remove link');
}
}
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-lg rounded-xl border border-theme bg-theme-primary shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<h2 id="project-dialog-title" class="text-lg font-semibold text-theme-primary">
{modalTitle}
</h2>
<button
type="button"
onclick={onClose}
class="rounded-lg p-1.5 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
aria-label="Close dialog"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Tabs -->
<div class="border-b border-theme px-6">
<div class="flex gap-4">
<button
type="button"
onclick={() => (activeTab = 'settings')}
class="relative py-3 text-sm font-medium transition-colors {activeTab === 'settings' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
>
Settings
{#if activeTab === 'settings'}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
{/if}
</button>
<button
type="button"
onclick={() => (activeTab = 'instructions')}
class="relative py-3 text-sm font-medium transition-colors {activeTab === 'instructions' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
>
Instructions
{#if activeTab === 'instructions'}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
{/if}
</button>
{#if projectId}
<button
type="button"
onclick={() => (activeTab = 'links')}
class="relative py-3 text-sm font-medium transition-colors {activeTab === 'links' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
>
Links ({links.length})
{#if activeTab === 'links'}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
{/if}
</button>
{/if}
</div>
</div>
<!-- Content -->
<div class="max-h-[50vh] overflow-y-auto px-6 py-4">
{#if activeTab === 'settings'}
<!-- Settings Tab -->
<div class="space-y-4">
<!-- Name -->
<div>
<label for="project-name" class="mb-1.5 block text-sm font-medium text-theme-secondary">
Name <span class="text-red-500">*</span>
</label>
<input
id="project-name"
type="text"
bind:value={name}
placeholder="My Project"
class="w-full rounded-lg border border-theme bg-theme-tertiary px-3 py-2 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
/>
</div>
<!-- Description -->
<div>
<label for="project-description" class="mb-1.5 block text-sm font-medium text-theme-secondary">
Description
</label>
<input
id="project-description"
type="text"
bind:value={description}
placeholder="Optional description"
class="w-full rounded-lg border border-theme bg-theme-tertiary px-3 py-2 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
/>
</div>
<!-- Color -->
<div>
<label class="mb-1.5 block text-sm font-medium text-theme-secondary">
Color
</label>
<div class="flex items-center gap-2">
{#each presetColors as presetColor}
<button
type="button"
onclick={() => (color = presetColor)}
class="h-6 w-6 rounded-full border-2 transition-transform hover:scale-110 {color === presetColor ? 'border-white shadow-lg' : 'border-transparent'}"
style="background-color: {presetColor}"
aria-label="Select color {presetColor}"
></button>
{/each}
<input
type="color"
bind:value={color}
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent"
title="Custom color"
/>
</div>
</div>
</div>
{:else if activeTab === 'instructions'}
<!-- Instructions Tab -->
<div>
<label for="project-instructions" class="mb-1.5 block text-sm font-medium text-theme-secondary">
Project Instructions
</label>
<p class="mb-2 text-xs text-theme-muted">
These instructions are injected into the system prompt for all chats in this project.
</p>
<textarea
id="project-instructions"
bind:value={instructions}
rows="10"
placeholder="You are helping with..."
class="w-full rounded-lg border border-theme bg-theme-tertiary px-3 py-2 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
></textarea>
</div>
{:else if activeTab === 'links'}
<!-- Links Tab -->
<div class="space-y-4">
<!-- Add new link form -->
<div class="rounded-lg border border-theme bg-theme-secondary/30 p-3">
<h4 class="mb-2 text-sm font-medium text-theme-secondary">Add Reference Link</h4>
<div class="space-y-2">
<input
type="url"
bind:value={newLinkUrl}
placeholder="https://..."
class="w-full rounded border border-theme bg-theme-tertiary px-2 py-1.5 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none"
/>
<input
type="text"
bind:value={newLinkTitle}
placeholder="Title (optional)"
class="w-full rounded border border-theme bg-theme-tertiary px-2 py-1.5 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none"
/>
<input
type="text"
bind:value={newLinkDescription}
placeholder="Description (optional)"
class="w-full rounded border border-theme bg-theme-tertiary px-2 py-1.5 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none"
/>
<button
type="button"
onclick={handleAddLink}
disabled={!newLinkUrl.trim()}
class="w-full rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
>
Add Link
</button>
</div>
</div>
<!-- Existing links -->
{#if links.length === 0}
<p class="py-4 text-center text-sm text-theme-muted">No links added yet</p>
{:else}
<div class="space-y-2">
{#each links as link (link.id)}
<div class="flex items-start gap-2 rounded-lg border border-theme bg-theme-secondary/30 p-2">
<div class="min-w-0 flex-1">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="block truncate text-sm font-medium text-emerald-500 hover:text-emerald-400"
>
{link.title}
</a>
{#if link.description}
<p class="truncate text-xs text-theme-muted">{link.description}</p>
{/if}
</div>
<button
type="button"
onclick={() => handleDeleteLink(link.id)}
class="shrink-0 rounded p-1 text-theme-muted hover:bg-red-900/50 hover:text-red-400"
aria-label="Remove link"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-theme px-6 py-4">
<div>
{#if projectId}
<button
type="button"
onclick={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"
>
Delete Project
</button>
{/if}
</div>
<div class="flex gap-3">
<button
type="button"
onclick={onClose}
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
>
Cancel
</button>
<button
type="button"
onclick={handleSave}
disabled={isLoading || !name.trim()}
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
>
{isLoading ? 'Saving...' : projectId ? 'Save Changes' : 'Create Project'}
</button>
</div>
</div>
</div>
</div>
{/if}
<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,155 @@
<script lang="ts">
/**
* GeneralTab - General settings including appearance, defaults, shortcuts, and about
*/
import { modelsState, uiState } from '$lib/stores';
import { getPrimaryModifierDisplay } from '$lib/utils';
const modifierKey = getPrimaryModifierDisplay();
// Local state for default model selection
let defaultModel = $state<string | null>(modelsState.selectedId);
// Save default model when it changes
function handleModelChange(): void {
if (defaultModel) {
modelsState.select(defaultModel);
}
}
</script>
<div class="space-y-8">
<!-- Appearance Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Appearance
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Dark Mode Toggle -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Dark Mode</p>
<p class="text-xs text-theme-muted">Toggle between light and dark theme</p>
</div>
<button
type="button"
onclick={() => uiState.toggleDarkMode()}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-theme {uiState.darkMode ? 'bg-purple-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={uiState.darkMode}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {uiState.darkMode ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
<!-- System Theme Sync -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Use System Theme</p>
<p class="text-xs text-theme-muted">Match your OS light/dark preference</p>
</div>
<button
type="button"
onclick={() => uiState.useSystemTheme()}
class="rounded-lg bg-theme-tertiary px-3 py-1.5 text-xs font-medium text-theme-secondary transition-colors hover:bg-theme-hover"
>
Sync with System
</button>
</div>
</div>
</section>
<!-- Chat Defaults Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Chat Defaults
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div>
<label for="default-model" class="text-sm font-medium text-theme-secondary">Default Model</label>
<p class="text-xs text-theme-muted mb-2">Model used for new conversations</p>
<select
id="default-model"
bind:value={defaultModel}
onchange={handleModelChange}
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-secondary focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
>
{#each modelsState.chatModels as model}
<option value={model.name}>{model.name}</option>
{/each}
</select>
</div>
</div>
</section>
<!-- Keyboard Shortcuts Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Keyboard Shortcuts
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">New Chat</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+N</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Search</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+K</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Toggle Sidebar</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+B</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Send Message</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Enter</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">New Line</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Shift+Enter</kbd>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
About
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="flex items-center gap-4">
<div class="rounded-lg bg-theme-tertiary p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
</div>
<div>
<h3 class="font-semibold text-theme-primary">Vessel</h3>
<p class="text-sm text-theme-muted">
A modern interface for local AI with chat, tools, and memory management.
</p>
</div>
</div>
</div>
</section>
</div>

View File

@@ -0,0 +1,291 @@
<script lang="ts">
/**
* KnowledgeTab - Knowledge Base management
*/
import { onMount } from 'svelte';
import {
listDocuments,
addDocument,
deleteDocument,
getKnowledgeBaseStats,
formatTokenCount,
EMBEDDING_MODELS,
DEFAULT_EMBEDDING_MODEL
} from '$lib/memory';
import type { StoredDocument } from '$lib/storage/db';
import { toastState, modelsState } from '$lib/stores';
import { ConfirmDialog } from '$lib/components/shared';
let documents = $state<StoredDocument[]>([]);
let stats = $state({ documentCount: 0, chunkCount: 0, totalTokens: 0 });
let isLoading = $state(true);
let isUploading = $state(false);
let uploadProgress = $state({ current: 0, total: 0 });
let selectedModel = $state(DEFAULT_EMBEDDING_MODEL);
let dragOver = $state(false);
let deleteConfirm = $state<{ show: boolean; doc: StoredDocument | null }>({ show: false, doc: null });
let fileInput: HTMLInputElement;
onMount(async () => {
await refreshData();
});
async function refreshData() {
isLoading = true;
try {
documents = await listDocuments();
stats = await getKnowledgeBaseStats();
} catch (error) {
console.error('Failed to load documents:', error);
toastState.error('Failed to load knowledge base');
} finally {
isLoading = false;
}
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await processFiles(Array.from(input.files));
}
input.value = '';
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
dragOver = false;
if (event.dataTransfer?.files) {
await processFiles(Array.from(event.dataTransfer.files));
}
}
async function processFiles(files: File[]) {
isUploading = true;
for (const file of files) {
try {
const content = await file.text();
if (!content.trim()) {
toastState.warning(`File "${file.name}" is empty, skipping`);
continue;
}
await addDocument(file.name, content, file.type || 'text/plain', {
embeddingModel: selectedModel,
onProgress: (current, total) => {
uploadProgress = { current, total };
}
});
toastState.success(`Added "${file.name}" to knowledge base`);
} catch (error) {
console.error(`Failed to process ${file.name}:`, error);
toastState.error(`Failed to add "${file.name}"`);
}
}
await refreshData();
isUploading = false;
uploadProgress = { current: 0, total: 0 };
}
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);
toastState.success(`Deleted "${doc.name}"`);
await refreshData();
} catch (error) {
console.error('Failed to delete document:', error);
toastState.error('Failed to delete document');
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
</script>
<div>
<!-- Header -->
<div class="mb-8">
<h2 class="text-xl font-bold text-theme-primary">Knowledge Base</h2>
<p class="mt-1 text-sm text-theme-muted">
Upload documents to enhance AI responses with your own knowledge
</p>
</div>
<!-- Stats -->
<div class="mb-6 grid grid-cols-3 gap-4">
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted">Documents</p>
<p class="mt-1 text-2xl font-semibold text-theme-primary">{stats.documentCount}</p>
</div>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted">Chunks</p>
<p class="mt-1 text-2xl font-semibold text-theme-primary">{stats.chunkCount}</p>
</div>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted">Total Tokens</p>
<p class="mt-1 text-2xl font-semibold text-theme-primary">{formatTokenCount(stats.totalTokens)}</p>
</div>
</div>
<!-- Upload Area -->
<div class="mb-8">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-lg font-semibold text-theme-primary">Upload Documents</h3>
<select
bind:value={selectedModel}
class="rounded-md border border-theme-subtle bg-theme-tertiary px-3 py-1.5 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{#each EMBEDDING_MODELS as model}
<option value={model}>{model}</option>
{/each}
</select>
</div>
<button
type="button"
class="w-full rounded-lg border-2 border-dashed p-8 text-center transition-colors {dragOver
? 'border-blue-500 bg-blue-900/20'
: 'border-theme-subtle hover:border-theme'}"
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
ondragleave={() => (dragOver = false)}
ondrop={handleDrop}
onclick={() => fileInput?.click()}
disabled={isUploading}
>
{#if isUploading}
<div class="flex flex-col items-center">
<svg class="h-8 w-8 animate-spin text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-3 text-sm text-theme-muted">Processing... ({uploadProgress.current}/{uploadProgress.total} chunks)</p>
</div>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z" />
</svg>
<p class="mt-3 text-sm text-theme-muted">Drag and drop files here, or click to browse</p>
<p class="mt-1 text-xs text-theme-muted">Supports .txt, .md, .json, and other text files</p>
{/if}
</button>
<input
bind:this={fileInput}
type="file"
multiple
accept=".txt,.md,.json,.csv,.xml,.html"
onchange={handleFileSelect}
class="hidden"
/>
</div>
<!-- Documents List -->
<div>
<h3 class="mb-4 text-lg font-semibold text-theme-primary">Documents</h3>
{#if isLoading}
<div class="flex items-center justify-center py-8">
<svg class="h-8 w-8 animate-spin text-theme-muted" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
{:else if documents.length === 0}
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<h4 class="mt-4 text-sm font-medium text-theme-muted">No documents yet</h4>
<p class="mt-1 text-sm text-theme-muted">Upload documents to build your knowledge base</p>
</div>
{:else}
<div class="space-y-3">
{#each documents as doc (doc.id)}
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-secondary p-4">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<div>
<h4 class="font-medium text-theme-primary">{doc.name}</h4>
<p class="text-xs text-theme-muted">{formatSize(doc.size)} · {doc.chunkCount} chunks · Added {formatDate(doc.createdAt)}</p>
</div>
</div>
<button
type="button"
onclick={() => handleDeleteClick(doc)}
class="rounded p-2 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
aria-label="Delete document"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Info Section -->
<section class="mt-8 rounded-lg border border-theme bg-theme-secondary/50 p-4">
<h4 class="flex items-center gap-2 text-sm font-medium text-theme-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
How RAG Works
</h4>
<p class="mt-2 text-sm text-theme-muted">
Documents are split into chunks and converted to embeddings. When you ask a question,
relevant chunks are found by similarity search and included in the AI's context.
</p>
{#if !modelsState.hasEmbeddingModel}
<p class="mt-2 text-sm text-amber-400">
<strong>No embedding model found.</strong> Install one to use the knowledge base:
<code class="ml-1 rounded bg-theme-tertiary px-1 text-theme-muted">ollama pull nomic-embed-text</code>
</p>
{:else}
<p class="mt-2 text-sm text-emerald-400">
Embedding model available: {modelsState.embeddingModels[0]?.name}
{#if modelsState.embeddingModels.length > 1}
<span class="text-theme-muted">(+{modelsState.embeddingModels.length - 1} more)</span>
{/if}
</p>
{/if}
</section>
</div>
<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

@@ -0,0 +1,373 @@
<script lang="ts">
/**
* MemoryTab - Model parameters, embedding model, auto-compact, and model-prompt defaults
*/
import { onMount } from 'svelte';
import { modelsState, settingsState, promptsState } from '$lib/stores';
import { modelPromptMappingsState } from '$lib/stores/model-prompt-mappings.svelte.js';
import { modelInfoService, type ModelInfo } from '$lib/services/model-info-service.js';
import { PARAMETER_RANGES, PARAMETER_LABELS, PARAMETER_DESCRIPTIONS, AUTO_COMPACT_RANGES } from '$lib/types/settings';
import { EMBEDDING_MODELS } from '$lib/memory/embeddings';
// Model info cache for the settings page
let modelInfoCache = $state<Map<string, ModelInfo>>(new Map());
let isLoadingModelInfo = $state(false);
// Load model info for all available models
onMount(async () => {
isLoadingModelInfo = true;
try {
const models = modelsState.chatModels;
const infos = await Promise.all(
models.map(async (model) => {
const info = await modelInfoService.getModelInfo(model.name);
return [model.name, info] as [string, ModelInfo];
})
);
modelInfoCache = new Map(infos);
} finally {
isLoadingModelInfo = false;
}
});
// Handle prompt selection for a model
async function handleModelPromptChange(modelName: string, promptId: string | null): Promise<void> {
if (promptId === null) {
await modelPromptMappingsState.removeMapping(modelName);
} else {
await modelPromptMappingsState.setMapping(modelName, promptId);
}
}
// Get the currently mapped prompt ID for a model
function getMappedPromptId(modelName: string): string | undefined {
return modelPromptMappingsState.getMapping(modelName);
}
// Get current model defaults for reset functionality
const currentModelDefaults = $derived(
modelsState.selectedId ? modelsState.getModelDefaults(modelsState.selectedId) : undefined
);
</script>
<div class="space-y-8">
<!-- Memory Management Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
Memory Management
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Embedding Model Selector -->
<div class="pb-4 border-b border-theme">
<label for="embedding-model" class="text-sm font-medium text-theme-secondary">Embedding Model</label>
<p class="text-xs text-theme-muted mb-2">Model used for semantic search and conversation indexing</p>
<select
id="embedding-model"
value={settingsState.embeddingModel}
onchange={(e) => settingsState.updateEmbeddingModel(e.currentTarget.value)}
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-secondary focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
>
{#each EMBEDDING_MODELS as model}
<option value={model}>{model}</option>
{/each}
</select>
{#if !modelsState.hasEmbeddingModel}
<p class="mt-2 text-xs text-amber-400">
No embedding model installed. Run <code class="bg-theme-tertiary px-1 rounded text-theme-muted">ollama pull {settingsState.embeddingModel}</code> to enable semantic search.
</p>
{:else}
{@const selectedInstalled = modelsState.embeddingModels.some(m => m.name.includes(settingsState.embeddingModel.split(':')[0]))}
{#if !selectedInstalled}
<p class="mt-2 text-xs text-amber-400">
Selected model not installed. Run <code class="bg-theme-tertiary px-1 rounded text-theme-muted">ollama pull {settingsState.embeddingModel}</code> or select an installed model.
</p>
<p class="mt-1 text-xs text-theme-muted">
Installed: {modelsState.embeddingModels.map(m => m.name).join(', ')}
</p>
{:else}
<p class="mt-2 text-xs text-emerald-400">
Model installed and ready.
</p>
{/if}
{/if}
</div>
<!-- Auto-Compact Toggle -->
<div class="flex items-center justify-between pb-4 border-b border-theme">
<div>
<p class="text-sm font-medium text-theme-secondary">Auto-Compact</p>
<p class="text-xs text-theme-muted">Automatically summarize older messages when context usage is high</p>
</div>
<button
type="button"
onclick={() => settingsState.toggleAutoCompact()}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.autoCompactEnabled ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={settingsState.autoCompactEnabled}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.autoCompactEnabled ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
{#if settingsState.autoCompactEnabled}
<!-- Threshold Slider -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="compact-threshold" class="text-sm font-medium text-theme-secondary">Context Threshold</label>
<span class="text-sm text-theme-muted">{settingsState.autoCompactThreshold}%</span>
</div>
<p class="text-xs text-theme-muted mb-2">Trigger compaction when context usage exceeds this percentage</p>
<input
id="compact-threshold"
type="range"
min={AUTO_COMPACT_RANGES.threshold.min}
max={AUTO_COMPACT_RANGES.threshold.max}
step={AUTO_COMPACT_RANGES.threshold.step}
value={settingsState.autoCompactThreshold}
oninput={(e) => settingsState.updateAutoCompactThreshold(parseInt(e.currentTarget.value))}
class="w-full accent-emerald-500"
/>
<div class="flex justify-between text-xs text-theme-muted mt-1">
<span>{AUTO_COMPACT_RANGES.threshold.min}%</span>
<span>{AUTO_COMPACT_RANGES.threshold.max}%</span>
</div>
</div>
<!-- Preserve Count -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="preserve-count" class="text-sm font-medium text-theme-secondary">Messages to Preserve</label>
<span class="text-sm text-theme-muted">{settingsState.autoCompactPreserveCount}</span>
</div>
<p class="text-xs text-theme-muted mb-2">Number of recent messages to keep intact (not summarized)</p>
<input
id="preserve-count"
type="range"
min={AUTO_COMPACT_RANGES.preserveCount.min}
max={AUTO_COMPACT_RANGES.preserveCount.max}
step={AUTO_COMPACT_RANGES.preserveCount.step}
value={settingsState.autoCompactPreserveCount}
oninput={(e) => settingsState.updateAutoCompactPreserveCount(parseInt(e.currentTarget.value))}
class="w-full accent-emerald-500"
/>
<div class="flex justify-between text-xs text-theme-muted mt-1">
<span>{AUTO_COMPACT_RANGES.preserveCount.min}</span>
<span>{AUTO_COMPACT_RANGES.preserveCount.max}</span>
</div>
</div>
{:else}
<p class="text-sm text-theme-muted py-2">
Enable auto-compact to automatically manage context usage. When enabled, older messages
will be summarized when context usage exceeds your threshold.
</p>
{/if}
</div>
</section>
<!-- Model Parameters Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
Model Parameters
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Use Custom Parameters Toggle -->
<div class="flex items-center justify-between pb-4 border-b border-theme">
<div>
<p class="text-sm font-medium text-theme-secondary">Use Custom Parameters</p>
<p class="text-xs text-theme-muted">Override model defaults with custom values</p>
</div>
<button
type="button"
onclick={() => settingsState.toggleCustomParameters(currentModelDefaults)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.useCustomParameters ? 'bg-orange-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={settingsState.useCustomParameters}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.useCustomParameters ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
{#if settingsState.useCustomParameters}
<!-- Temperature -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="temperature" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.temperature}</label>
<span class="text-sm text-theme-muted">{settingsState.temperature.toFixed(2)}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.temperature}</p>
<input
id="temperature"
type="range"
min={PARAMETER_RANGES.temperature.min}
max={PARAMETER_RANGES.temperature.max}
step={PARAMETER_RANGES.temperature.step}
value={settingsState.temperature}
oninput={(e) => settingsState.updateParameter('temperature', parseFloat(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Top K -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="top_k" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.top_k}</label>
<span class="text-sm text-theme-muted">{settingsState.top_k}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.top_k}</p>
<input
id="top_k"
type="range"
min={PARAMETER_RANGES.top_k.min}
max={PARAMETER_RANGES.top_k.max}
step={PARAMETER_RANGES.top_k.step}
value={settingsState.top_k}
oninput={(e) => settingsState.updateParameter('top_k', parseInt(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Top P -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="top_p" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.top_p}</label>
<span class="text-sm text-theme-muted">{settingsState.top_p.toFixed(2)}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.top_p}</p>
<input
id="top_p"
type="range"
min={PARAMETER_RANGES.top_p.min}
max={PARAMETER_RANGES.top_p.max}
step={PARAMETER_RANGES.top_p.step}
value={settingsState.top_p}
oninput={(e) => settingsState.updateParameter('top_p', parseFloat(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Context Length -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="num_ctx" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.num_ctx}</label>
<span class="text-sm text-theme-muted">{settingsState.num_ctx.toLocaleString()}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.num_ctx}</p>
<input
id="num_ctx"
type="range"
min={PARAMETER_RANGES.num_ctx.min}
max={PARAMETER_RANGES.num_ctx.max}
step={PARAMETER_RANGES.num_ctx.step}
value={settingsState.num_ctx}
oninput={(e) => settingsState.updateParameter('num_ctx', parseInt(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Reset Button -->
<div class="pt-2">
<button
type="button"
onclick={() => settingsState.resetToDefaults(currentModelDefaults)}
class="text-sm text-orange-400 hover:text-orange-300 transition-colors"
>
Reset to model defaults
</button>
</div>
{:else}
<p class="text-sm text-theme-muted py-2">
Using model defaults. Enable custom parameters to adjust temperature, sampling, and context length.
</p>
{/if}
</div>
</section>
<!-- Model-Prompt Defaults Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
Model-Prompt Defaults
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted mb-4">
Set default system prompts for specific models. When no other prompt is selected, the model's default will be used automatically.
</p>
{#if isLoadingModelInfo}
<div class="flex items-center justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-theme-subtle border-t-violet-500"></div>
<span class="ml-2 text-sm text-theme-muted">Loading model info...</span>
</div>
{:else if modelsState.chatModels.length === 0}
<p class="text-sm text-theme-muted py-4 text-center">
No models available. Make sure Ollama is running.
</p>
{:else}
<div class="space-y-3">
{#each modelsState.chatModels as model (model.name)}
{@const modelInfo = modelInfoCache.get(model.name)}
{@const mappedPromptId = getMappedPromptId(model.name)}
<div class="rounded-lg border border-theme-subtle bg-theme-tertiary p-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-theme-primary text-sm">{model.name}</span>
{#if modelInfo?.capabilities && modelInfo.capabilities.length > 0}
{#each modelInfo.capabilities as cap (cap)}
<span class="rounded bg-violet-900/50 px-1.5 py-0.5 text-xs text-violet-300">
{cap}
</span>
{/each}
{/if}
{#if modelInfo?.systemPrompt}
<span class="rounded bg-amber-900/50 px-1.5 py-0.5 text-xs text-amber-300" title="This model has a built-in system prompt">
embedded
</span>
{/if}
</div>
</div>
<select
value={mappedPromptId ?? ''}
onchange={(e) => {
const value = e.currentTarget.value;
handleModelPromptChange(model.name, value === '' ? null : value);
}}
class="rounded-lg border border-theme-subtle bg-theme-secondary px-2 py-1 text-sm text-theme-secondary focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value="">
{modelInfo?.systemPrompt ? 'Use embedded prompt' : 'No default'}
</option>
{#each promptsState.prompts as prompt (prompt.id)}
<option value={prompt.id}>{prompt.name}</option>
{/each}
</select>
</div>
{#if modelInfo?.systemPrompt}
<p class="mt-2 text-xs text-theme-muted line-clamp-2">
<span class="font-medium text-amber-400">Embedded:</span> {modelInfo.systemPrompt}
</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</section>
</div>

View File

@@ -0,0 +1,966 @@
<script lang="ts">
/**
* ModelsTab - Model browser and management
* Browse and search models from ollama.com, manage local models
*/
import { onMount } from 'svelte';
import { modelRegistry } from '$lib/stores/model-registry.svelte';
import { localModelsState } from '$lib/stores/local-models.svelte';
import { modelsState } from '$lib/stores/models.svelte';
import { modelOperationsState } from '$lib/stores/model-operations.svelte';
import { ModelCard } from '$lib/components/models';
import PullModelDialog from '$lib/components/models/PullModelDialog.svelte';
import ModelEditorDialog from '$lib/components/models/ModelEditorDialog.svelte';
import { fetchTagSizes, type RemoteModel } from '$lib/api/model-registry';
import { modelInfoService, type ModelInfo } from '$lib/services/model-info-service';
import type { ModelEditorMode } from '$lib/stores/model-creation.svelte';
// Search debounce
let searchInput = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleSearchInput(e: Event): void {
const value = (e.target as HTMLInputElement).value;
searchInput = value;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
modelRegistry.search(value);
}, 300);
}
function handleTypeFilter(type: 'official' | 'community' | ''): void {
modelRegistry.filterByType(type);
}
// Selected model for details panel
let selectedModel = $state<RemoteModel | null>(null);
let selectedTag = $state<string>('');
let pulling = $state(false);
let pullProgress = $state<{ status: string; completed?: number; total?: number } | null>(null);
let pullError = $state<string | null>(null);
let loadingSizes = $state(false);
let capabilitiesVerified = $state(false);
async function handleSelectModel(model: RemoteModel): Promise<void> {
selectedModel = model;
selectedTag = model.tags[0] || '';
pullProgress = null;
pullError = null;
capabilitiesVerified = false;
if (!model.tagSizes || Object.keys(model.tagSizes).length === 0) {
loadingSizes = true;
try {
const updatedModel = await fetchTagSizes(model.slug);
selectedModel = { ...model, tagSizes: updatedModel.tagSizes };
} catch (err) {
console.error('Failed to fetch tag sizes:', err);
} finally {
loadingSizes = false;
}
}
try {
const realCapabilities = await modelsState.fetchCapabilities(model.slug);
if (modelsState.hasCapability(model.slug, 'completion') || realCapabilities.length > 0) {
selectedModel = { ...selectedModel!, capabilities: realCapabilities };
capabilitiesVerified = true;
}
} catch {
capabilitiesVerified = false;
}
}
function closeDetails(): void {
selectedModel = null;
selectedTag = '';
pullProgress = null;
pullError = null;
}
async function pullModel(): Promise<void> {
if (!selectedModel || pulling) return;
const modelName = selectedTag
? `${selectedModel.slug}:${selectedTag}`
: selectedModel.slug;
pulling = true;
pullError = null;
pullProgress = { status: 'Starting pull...' };
try {
const response = await fetch('/api/v1/ollama/api/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName })
});
if (!response.ok) {
throw new Error(`Failed to pull model: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.error) {
pullError = data.error;
break;
}
pullProgress = {
status: data.status || 'Pulling...',
completed: data.completed,
total: data.total
};
} catch {
// Skip invalid JSON
}
}
}
if (!pullError) {
pullProgress = { status: 'Pull complete!' };
await modelsState.refresh();
modelsState.select(modelName);
}
} catch (err) {
pullError = err instanceof Error ? err.message : 'Failed to pull model';
} finally {
pulling = false;
}
}
function formatDate(dateStr: string | undefined): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return `${value.toFixed(i > 1 ? 1 : 0)} ${units[i]}`;
}
let deleteConfirm = $state<string | null>(null);
let deleting = $state(false);
let deleteError = $state<string | null>(null);
let modelEditorOpen = $state(false);
let modelEditorMode = $state<ModelEditorMode>('create');
let editingModelName = $state<string | undefined>(undefined);
let editingSystemPrompt = $state<string | undefined>(undefined);
let editingBaseModel = $state<string | undefined>(undefined);
let modelInfoCache = $state<Map<string, ModelInfo>>(new Map());
function openCreateDialog(): void {
modelEditorMode = 'create';
editingModelName = undefined;
editingSystemPrompt = undefined;
editingBaseModel = undefined;
modelEditorOpen = true;
}
async function openEditDialog(modelName: string): Promise<void> {
const info = await modelInfoService.getModelInfo(modelName);
if (!info.systemPrompt) return;
const localModel = localModelsState.models.find((m) => m.name === modelName);
const baseModel = localModel?.family || modelName;
modelEditorMode = 'edit';
editingModelName = modelName;
editingSystemPrompt = info.systemPrompt;
editingBaseModel = baseModel;
modelEditorOpen = true;
}
function closeModelEditor(): void {
modelEditorOpen = false;
localModelsState.refresh();
}
async function fetchModelInfoForLocalModels(): Promise<void> {
const newCache = new Map<string, ModelInfo>();
for (const model of localModelsState.models) {
try {
const info = await modelInfoService.getModelInfo(model.name);
newCache.set(model.name, info);
} catch {
// Ignore errors
}
}
modelInfoCache = newCache;
}
function hasEmbeddedPrompt(modelName: string): boolean {
const info = modelInfoCache.get(modelName);
return info?.systemPrompt !== null && info?.systemPrompt !== undefined && info.systemPrompt.length > 0;
}
async function deleteModel(modelName: string): Promise<void> {
if (deleting) return;
deleting = true;
deleteError = null;
try {
const response = await fetch('/api/v1/ollama/api/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `Failed to delete: ${response.statusText}`);
}
await localModelsState.refresh();
await modelsState.refresh();
deleteConfirm = null;
} catch (err) {
deleteError = err instanceof Error ? err.message : 'Failed to delete model';
} finally {
deleting = false;
}
}
let activeTab = $state<'local' | 'browse'>('local');
let localSearchInput = $state('');
let localSearchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleLocalSearchInput(e: Event): void {
const value = (e.target as HTMLInputElement).value;
localSearchInput = value;
if (localSearchTimeout) clearTimeout(localSearchTimeout);
localSearchTimeout = setTimeout(() => {
localModelsState.search(value);
}, 300);
}
$effect(() => {
if (localModelsState.models.length > 0) {
fetchModelInfoForLocalModels();
}
});
onMount(() => {
localModelsState.init();
modelRegistry.init();
modelsState.refresh().then(() => {
modelsState.fetchAllCapabilities();
});
});
</script>
<div class="flex h-full overflow-hidden">
<!-- Main Content -->
<div class="flex-1 overflow-y-auto">
<!-- Header -->
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h2 class="text-xl font-bold text-theme-primary">Models</h2>
<p class="mt-1 text-sm text-theme-muted">
Manage local models and browse ollama.com
</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
{#if activeTab === 'browse' && modelRegistry.syncStatus}
<div class="text-right text-xs text-theme-muted">
<div>{modelRegistry.syncStatus.modelCount} models cached</div>
<div>Last sync: {formatDate(modelRegistry.syncStatus.lastSync ?? undefined)}</div>
</div>
{/if}
{#if activeTab === 'browse'}
<button
type="button"
onclick={() => modelRegistry.sync()}
disabled={modelRegistry.syncing}
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if modelRegistry.syncing}
<svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Syncing...</span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Sync Models</span>
{/if}
</button>
{:else}
<button
type="button"
onclick={openCreateDialog}
class="flex items-center gap-2 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-violet-500"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
<span>Create Custom</span>
</button>
<button
type="button"
onclick={() => modelOperationsState.openPullDialog()}
class="flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-sky-500"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Pull Model</span>
</button>
<button
type="button"
onclick={() => localModelsState.checkUpdates()}
disabled={localModelsState.isCheckingUpdates}
class="flex items-center gap-2 rounded-lg border border-amber-700 bg-amber-900/20 px-4 py-2 text-sm font-medium text-amber-300 transition-colors hover:bg-amber-900/40 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if localModelsState.isCheckingUpdates}
<svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Checking...</span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span>Check Updates</span>
{/if}
</button>
<button
type="button"
onclick={() => localModelsState.refresh()}
disabled={localModelsState.loading}
class="flex items-center gap-2 rounded-lg border border-theme bg-theme-secondary px-4 py-2 text-sm font-medium text-theme-secondary transition-colors hover:bg-theme-tertiary disabled:cursor-not-allowed disabled:opacity-50"
>
{#if localModelsState.loading}
<svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{/if}
<span>Refresh</span>
</button>
{/if}
</div>
</div>
<!-- Tabs -->
<div class="mb-6 flex border-b border-theme">
<button
type="button"
onclick={() => activeTab = 'local'}
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'local'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-theme-muted hover:text-theme-primary'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
Local Models
<span class="rounded-full bg-theme-tertiary px-2 py-0.5 text-xs">{localModelsState.total}</span>
{#if localModelsState.updatesAvailable > 0}
<span class="rounded-full bg-amber-600 px-2 py-0.5 text-xs text-theme-primary" title="{localModelsState.updatesAvailable} update{localModelsState.updatesAvailable !== 1 ? 's' : ''} available">
{localModelsState.updatesAvailable}
</span>
{/if}
</button>
<button
type="button"
onclick={() => activeTab = 'browse'}
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'browse'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-theme-muted hover:text-theme-primary'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
Browse ollama.com
</button>
</div>
<!-- Local Models Tab -->
{#if activeTab === 'local'}
{#if deleteError}
<div class="mb-4 rounded-lg border border-red-900/50 bg-red-900/20 p-4">
<div class="flex items-center gap-2 text-red-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{deleteError}</span>
<button type="button" onclick={() => deleteError = null} class="ml-auto text-red-400 hover:text-red-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/if}
<!-- Local Models Search/Filter Bar -->
<div class="mb-4 flex flex-wrap items-center gap-4">
<div class="relative flex-1 min-w-[200px]">
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={localSearchInput}
oninput={handleLocalSearchInput}
placeholder="Search local models..."
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-theme-primary placeholder-theme-placeholder focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{#if localModelsState.families.length > 0}
<select
value={localModelsState.familyFilter}
onchange={(e) => localModelsState.filterByFamily((e.target as HTMLSelectElement).value)}
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">All Families</option>
{#each localModelsState.families as family}
<option value={family}>{family}</option>
{/each}
</select>
{/if}
<select
value={localModelsState.sortBy}
onchange={(e) => localModelsState.setSort((e.target as HTMLSelectElement).value as import('$lib/api/model-registry').LocalModelSortOption)}
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
<option value="size_desc">Largest</option>
<option value="size_asc">Smallest</option>
<option value="modified_desc">Recently Modified</option>
<option value="modified_asc">Oldest Modified</option>
</select>
{#if localModelsState.searchQuery || localModelsState.familyFilter || localModelsState.sortBy !== 'name_asc'}
<button
type="button"
onclick={() => { localModelsState.clearFilters(); localSearchInput = ''; }}
class="text-sm text-theme-muted hover:text-theme-primary"
>
Clear filters
</button>
{/if}
</div>
{#if localModelsState.loading}
<div class="space-y-3">
{#each Array(3) as _}
<div class="animate-pulse rounded-lg border border-theme bg-theme-secondary p-4">
<div class="flex items-center justify-between">
<div class="h-5 w-48 rounded bg-theme-tertiary"></div>
<div class="h-5 w-20 rounded bg-theme-tertiary"></div>
</div>
</div>
{/each}
</div>
{:else if localModelsState.models.length === 0}
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-12 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<h3 class="mt-4 text-sm font-medium text-theme-muted">
{#if localModelsState.searchQuery || localModelsState.familyFilter}
No models match your filters
{:else}
No local models
{/if}
</h3>
<p class="mt-1 text-sm text-theme-muted">
{#if localModelsState.searchQuery || localModelsState.familyFilter}
Try adjusting your search or filters
{:else}
Browse ollama.com to pull models
{/if}
</p>
{#if !localModelsState.searchQuery && !localModelsState.familyFilter}
<button
type="button"
onclick={() => activeTab = 'browse'}
class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700"
>
Browse Models
</button>
{/if}
</div>
{:else}
<div class="space-y-2">
{#each localModelsState.models as model (model.name)}
{@const caps = modelsState.getCapabilities(model.name) ?? []}
<div class="group rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:border-theme-subtle">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
<h3 class="font-medium text-theme-primary">{model.name}</h3>
{#if model.name === modelsState.selectedId}
<span class="rounded bg-blue-900/50 px-2 py-0.5 text-xs text-blue-300">Selected</span>
{/if}
{#if localModelsState.hasUpdate(model.name)}
<span class="rounded bg-amber-600 px-2 py-0.5 text-xs font-medium text-theme-primary" title="Update available">
Update
</span>
{/if}
{#if hasEmbeddedPrompt(model.name)}
<span class="rounded bg-violet-900/50 px-2 py-0.5 text-xs text-violet-300" title="Custom model with embedded system prompt">
Custom
</span>
{/if}
</div>
<div class="mt-1 flex items-center gap-4 text-xs text-theme-muted">
<span>{formatBytes(model.size)}</span>
<span>Family: {model.family}</span>
<span>Parameters: {model.parameterSize}</span>
<span>Quantization: {model.quantizationLevel}</span>
</div>
{#if caps.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#if caps.includes('vision')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-purple-900/50 text-purple-300">
<span>👁</span><span>Vision</span>
</span>
{/if}
{#if caps.includes('tools')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-blue-900/50 text-blue-300">
<span>🔧</span><span>Tools</span>
</span>
{/if}
{#if caps.includes('thinking')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-pink-900/50 text-pink-300">
<span>🧠</span><span>Thinking</span>
</span>
{/if}
{#if caps.includes('embedding')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-amber-900/50 text-amber-300">
<span>📊</span><span>Embedding</span>
</span>
{/if}
{#if caps.includes('code')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-emerald-900/50 text-emerald-300">
<span>💻</span><span>Code</span>
</span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2">
{#if deleteConfirm === model.name}
<span class="text-sm text-theme-muted">Delete?</span>
<button
type="button"
onclick={() => deleteModel(model.name)}
disabled={deleting}
class="rounded bg-red-600 px-3 py-1 text-sm font-medium text-theme-primary hover:bg-red-700 disabled:opacity-50"
>
{deleting ? 'Deleting...' : 'Yes'}
</button>
<button
type="button"
onclick={() => deleteConfirm = null}
disabled={deleting}
class="rounded bg-theme-tertiary px-3 py-1 text-sm font-medium text-theme-secondary hover:bg-theme-secondary disabled:opacity-50"
>
No
</button>
{:else}
{#if hasEmbeddedPrompt(model.name)}
<button
type="button"
onclick={() => openEditDialog(model.name)}
class="rounded p-2 text-theme-muted opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-violet-400 group-hover:opacity-100"
title="Edit system prompt"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
{/if}
<button
type="button"
onclick={() => deleteConfirm = model.name}
class="rounded p-2 text-theme-muted opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-red-400 group-hover:opacity-100"
title="Delete model"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{#if localModelsState.totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
<button
type="button"
onclick={() => localModelsState.prevPage()}
disabled={!localModelsState.hasPrevPage}
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary hover:bg-theme-tertiary disabled:cursor-not-allowed disabled:opacity-50"
>
← Prev
</button>
<span class="px-3 text-sm text-theme-muted">
Page {localModelsState.currentPage + 1} of {localModelsState.totalPages}
</span>
<button
type="button"
onclick={() => localModelsState.nextPage()}
disabled={!localModelsState.hasNextPage}
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary hover:bg-theme-tertiary disabled:cursor-not-allowed disabled:opacity-50"
>
Next →
</button>
</div>
{/if}
{/if}
{:else}
<!-- Browse Tab - Search and Filters -->
<div class="mb-6 flex flex-wrap items-center gap-4">
<div class="relative flex-1 min-w-[200px]">
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchInput}
oninput={handleSearchInput}
placeholder="Search models..."
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-theme-primary placeholder-theme-placeholder focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div class="flex rounded-lg border border-theme bg-theme-secondary p-1">
<button
type="button"
onclick={() => handleTypeFilter('')}
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === ''
? 'bg-theme-tertiary text-theme-primary'
: 'text-theme-muted hover:text-theme-primary'}"
>
All
</button>
<button
type="button"
onclick={() => handleTypeFilter('official')}
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === 'official'
? 'bg-blue-600 text-theme-primary'
: 'text-theme-muted hover:text-theme-primary'}"
>
Official
</button>
<button
type="button"
onclick={() => handleTypeFilter('community')}
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === 'community'
? 'bg-theme-tertiary text-theme-primary'
: 'text-theme-muted hover:text-theme-primary'}"
>
Community
</button>
</div>
<div class="flex items-center gap-2">
<label for="sort-select" class="text-sm text-theme-muted">Sort:</label>
<select
id="sort-select"
value={modelRegistry.sortBy}
onchange={(e) => modelRegistry.setSort((e.target as HTMLSelectElement).value as import('$lib/api/model-registry').ModelSortOption)}
class="rounded-lg border border-theme bg-theme-secondary px-3 py-1.5 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="pulls_desc">Most Popular</option>
<option value="pulls_asc">Least Popular</option>
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
<option value="updated_desc">Recently Updated</option>
</select>
</div>
<div class="text-sm text-theme-muted">
{modelRegistry.total} model{modelRegistry.total !== 1 ? 's' : ''} found
</div>
</div>
<!-- Capability Filters -->
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="text-sm text-theme-muted">Capabilities:</span>
<button type="button" onclick={() => modelRegistry.toggleCapability('vision')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('vision') ? 'bg-purple-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
<span>👁</span><span>Vision</span>
</button>
<button type="button" onclick={() => modelRegistry.toggleCapability('tools')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('tools') ? 'bg-blue-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
<span>🔧</span><span>Tools</span>
</button>
<button type="button" onclick={() => modelRegistry.toggleCapability('thinking')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('thinking') ? 'bg-pink-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
<span>🧠</span><span>Thinking</span>
</button>
<button type="button" onclick={() => modelRegistry.toggleCapability('embedding')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('embedding') ? 'bg-amber-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
<span>📊</span><span>Embedding</span>
</button>
<button type="button" onclick={() => modelRegistry.toggleCapability('cloud')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('cloud') ? 'bg-cyan-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
<span>☁️</span><span>Cloud</span>
</button>
<span class="ml-2 text-xs text-theme-muted opacity-60">from ollama.com</span>
</div>
<!-- Size Range Filters -->
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="text-sm text-theme-muted">Size:</span>
<button type="button" onclick={() => modelRegistry.toggleSizeRange('small')} class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('small') ? 'bg-emerald-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">≤3B</button>
<button type="button" onclick={() => modelRegistry.toggleSizeRange('medium')} class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('medium') ? 'bg-emerald-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">4-13B</button>
<button type="button" onclick={() => modelRegistry.toggleSizeRange('large')} class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('large') ? 'bg-emerald-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">14-70B</button>
<button type="button" onclick={() => modelRegistry.toggleSizeRange('xlarge')} class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('xlarge') ? 'bg-emerald-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">>70B</button>
</div>
<!-- Family Filter + Clear -->
<div class="mb-6 flex flex-wrap items-center gap-4">
{#if modelRegistry.availableFamilies.length > 0}
<div class="flex items-center gap-2">
<span class="text-sm text-theme-muted">Family:</span>
<select
value={modelRegistry.selectedFamily}
onchange={(e) => modelRegistry.setFamily((e.target as HTMLSelectElement).value)}
class="rounded-lg border border-theme bg-theme-secondary px-3 py-1.5 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">All Families</option>
{#each modelRegistry.availableFamilies as family}
<option value={family}>{family}</option>
{/each}
</select>
</div>
{/if}
{#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.selectedSizeRanges.length > 0 || modelRegistry.selectedFamily || modelRegistry.modelType || modelRegistry.searchQuery || modelRegistry.sortBy !== 'pulls_desc'}
<button
type="button"
onclick={() => { modelRegistry.clearFilters(); searchInput = ''; }}
class="text-sm text-theme-muted hover:text-theme-primary"
>
Clear all filters
</button>
{/if}
</div>
{#if modelRegistry.error}
<div class="mb-6 rounded-lg border border-red-900/50 bg-red-900/20 p-4">
<div class="flex items-center gap-2 text-red-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{modelRegistry.error}</span>
</div>
</div>
{/if}
{#if modelRegistry.loading}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each Array(6) as _}
<div class="animate-pulse rounded-lg border border-theme bg-theme-secondary p-4">
<div class="flex items-start justify-between">
<div class="h-5 w-32 rounded bg-theme-tertiary"></div>
<div class="h-5 w-16 rounded bg-theme-tertiary"></div>
</div>
<div class="mt-3 h-4 w-full rounded bg-theme-tertiary"></div>
<div class="mt-2 h-4 w-2/3 rounded bg-theme-tertiary"></div>
</div>
{/each}
</div>
{:else if modelRegistry.models.length === 0}
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-12 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611l-.628.105a9.002 9.002 0 01-9.014 0l-.628-.105c-1.717-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg>
<h3 class="mt-4 text-sm font-medium text-theme-muted">No models found</h3>
<p class="mt-1 text-sm text-theme-muted">
{#if modelRegistry.searchQuery || modelRegistry.modelType}
Try adjusting your search or filters
{:else}
Click "Sync Models" to fetch models from ollama.com
{/if}
</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each modelRegistry.models as model (model.slug)}
<ModelCard {model} onSelect={handleSelectModel} />
{/each}
</div>
{#if modelRegistry.totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
<button type="button" onclick={() => modelRegistry.prevPage()} disabled={!modelRegistry.hasPrevPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="text-sm text-theme-muted">Page {modelRegistry.currentPage + 1} of {modelRegistry.totalPages}</span>
<button type="button" onclick={() => modelRegistry.nextPage()} disabled={!modelRegistry.hasNextPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/if}
{/if}
{/if}
</div>
<!-- Model Details Sidebar -->
{#if selectedModel}
<div class="w-80 flex-shrink-0 overflow-y-auto border-l border-theme bg-theme-secondary p-4">
<div class="mb-4 flex items-start justify-between">
<h3 class="text-lg font-semibold text-theme-primary">{selectedModel.name}</h3>
<button type="button" onclick={closeDetails} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="mb-4">
<span class="rounded px-2 py-1 text-xs {selectedModel.modelType === 'official' ? 'bg-blue-900/50 text-blue-300' : 'bg-theme-tertiary text-theme-muted'}">
{selectedModel.modelType}
</span>
</div>
{#if selectedModel.description}
<div class="mb-4">
<h4 class="mb-2 text-sm font-medium text-theme-secondary">Description</h4>
<p class="text-sm text-theme-muted">{selectedModel.description}</p>
</div>
{/if}
{#if selectedModel.capabilities.length > 0}
<div class="mb-4">
<h4 class="mb-2 flex items-center gap-2 text-sm font-medium text-theme-secondary">
<span>Capabilities</span>
{#if capabilitiesVerified}
<span class="inline-flex items-center gap-1 rounded bg-green-900/30 px-1.5 py-0.5 text-xs text-green-400">✓ verified</span>
{:else}
<span class="inline-flex items-center gap-1 rounded bg-amber-900/30 px-1.5 py-0.5 text-xs text-amber-400">unverified</span>
{/if}
</h4>
<div class="flex flex-wrap gap-2">
{#each selectedModel.capabilities as cap}
<span class="rounded bg-theme-tertiary px-2 py-1 text-xs text-theme-secondary">{cap}</span>
{/each}
</div>
</div>
{/if}
<!-- Pull Section -->
<div class="mb-4">
<h4 class="mb-2 text-sm font-medium text-theme-secondary">Pull Model</h4>
{#if selectedModel.tags.length > 0}
<select bind:value={selectedTag} disabled={pulling} class="mb-2 w-full rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary disabled:opacity-50">
{#each selectedModel.tags as tag}
{@const size = selectedModel.tagSizes?.[tag]}
<option value={tag}>{selectedModel.slug}:{tag} {size ? `(${formatBytes(size)})` : ''}</option>
{/each}
</select>
{/if}
<button type="button" onclick={pullModel} disabled={pulling} class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700 disabled:opacity-50">
{#if pulling}
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
Pulling...
{:else}
Pull Model
{/if}
</button>
{#if pullProgress}
<div class="mt-2 text-xs text-theme-muted">{pullProgress.status}</div>
{#if pullProgress.completed !== undefined && pullProgress.total}
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-theme-tertiary">
<div class="h-full bg-blue-500 transition-all" style="width: {Math.round((pullProgress.completed / pullProgress.total) * 100)}%"></div>
</div>
{/if}
{/if}
{#if pullError}
<div class="mt-2 rounded border border-red-900/50 bg-red-900/20 p-2 text-xs text-red-400">{pullError}</div>
{/if}
</div>
<a href={selectedModel.url} target="_blank" rel="noopener noreferrer" class="flex w-full items-center justify-center gap-2 rounded-lg border border-theme bg-theme-secondary px-4 py-2 text-sm text-theme-secondary hover:bg-theme-tertiary">
View on ollama.com
</a>
</div>
{/if}
</div>
<PullModelDialog />
<ModelEditorDialog isOpen={modelEditorOpen} mode={modelEditorMode} editingModel={editingModelName} currentSystemPrompt={editingSystemPrompt} baseModel={editingBaseModel} onClose={closeModelEditor} />
{#if modelOperationsState.activePulls.size > 0}
<div class="fixed bottom-0 left-0 right-0 z-40 border-t border-theme bg-theme-secondary/95 p-4 backdrop-blur-sm">
<div class="mx-auto max-w-4xl space-y-3">
<h3 class="text-sm font-medium text-theme-secondary">Active Downloads</h3>
{#each [...modelOperationsState.activePulls.entries()] as [name, pull]}
<div class="rounded-lg bg-theme-primary/50 p-3">
<div class="mb-2 flex items-center justify-between">
<span class="font-medium text-theme-secondary">{name}</span>
<button type="button" onclick={() => modelOperationsState.cancelPull(name)} class="text-xs text-red-400 hover:text-red-300">Cancel</button>
</div>
<div class="mb-1 flex items-center gap-3">
<div class="h-2 flex-1 overflow-hidden rounded-full bg-theme-tertiary">
<div class="h-full bg-sky-500 transition-all" style="width: {pull.progress.percent}%"></div>
</div>
<span class="text-xs text-theme-muted">{pull.progress.percent}%</span>
</div>
<div class="flex items-center justify-between text-xs text-theme-muted">
<span>{pull.progress.status}</span>
{#if pull.progress.speed}
<span>{modelOperationsState.formatBytes(pull.progress.speed)}/s</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,462 @@
<script lang="ts">
/**
* PromptsTab - System prompts management
*/
import { promptsState, type Prompt } from '$lib/stores';
import {
getAllPromptTemplates,
getPromptCategories,
categoryInfo,
type PromptTemplate,
type PromptCategory
} from '$lib/prompts/templates';
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);
let formName = $state('');
let formDescription = $state('');
let formContent = $state('');
let formIsDefault = $state(false);
let formTargetCapabilities = $state<string[]>([]);
let isSaving = $state(false);
let selectedCategory = $state<PromptCategory | 'all'>('all');
let previewTemplate = $state<PromptTemplate | null>(null);
let addingTemplateId = $state<string | null>(null);
const templates = getAllPromptTemplates();
const categories = getPromptCategories();
const filteredTemplates = $derived(
selectedCategory === 'all'
? templates
: templates.filter((t) => t.category === selectedCategory)
);
const CAPABILITIES = [
{ id: 'code', label: 'Code', description: 'Auto-use with coding models' },
{ id: 'vision', label: 'Vision', description: 'Auto-use with vision models' },
{ id: 'thinking', label: 'Thinking', description: 'Auto-use with reasoning models' },
{ id: 'tools', label: 'Tools', description: 'Auto-use with tool-capable models' }
] as const;
function openCreateEditor(): void {
editingPrompt = null;
formName = '';
formDescription = '';
formContent = '';
formIsDefault = false;
formTargetCapabilities = [];
showEditor = true;
}
function openEditEditor(prompt: Prompt): void {
editingPrompt = prompt;
formName = prompt.name;
formDescription = prompt.description;
formContent = prompt.content;
formIsDefault = prompt.isDefault;
formTargetCapabilities = prompt.targetCapabilities ?? [];
showEditor = true;
}
function closeEditor(): void {
showEditor = false;
editingPrompt = null;
}
async function handleSave(): Promise<void> {
if (!formName.trim() || !formContent.trim()) return;
isSaving = true;
try {
const capabilities = formTargetCapabilities.length > 0 ? formTargetCapabilities : undefined;
if (editingPrompt) {
await promptsState.update(editingPrompt.id, {
name: formName.trim(),
description: formDescription.trim(),
content: formContent,
isDefault: formIsDefault,
targetCapabilities: capabilities ?? []
});
} else {
await promptsState.add({
name: formName.trim(),
description: formDescription.trim(),
content: formContent,
isDefault: formIsDefault,
targetCapabilities: capabilities
});
}
closeEditor();
} finally {
isSaving = false;
}
}
function toggleCapability(capId: string): void {
if (formTargetCapabilities.includes(capId)) {
formTargetCapabilities = formTargetCapabilities.filter((c) => c !== capId);
} else {
formTargetCapabilities = [...formTargetCapabilities, capId];
}
}
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> {
if (prompt.isDefault) {
await promptsState.clearDefault();
} else {
await promptsState.setDefault(prompt.id);
}
}
function handleSetActive(prompt: Prompt): void {
if (promptsState.activePromptId === prompt.id) {
promptsState.setActive(null);
} else {
promptsState.setActive(prompt.id);
}
}
async function addTemplateToLibrary(template: PromptTemplate): Promise<void> {
addingTemplateId = template.id;
try {
await promptsState.add({
name: template.name,
description: template.description,
content: template.content,
isDefault: false,
targetCapabilities: template.targetCapabilities
});
activeTab = 'my-prompts';
} finally {
addingTemplateId = null;
}
}
function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
</script>
<div>
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-theme-primary">System Prompts</h2>
<p class="mt-1 text-sm text-theme-muted">
Create and manage system prompt templates for conversations
</p>
</div>
{#if activeTab === 'my-prompts'}
<button
type="button"
onclick={openCreateEditor}
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create Prompt
</button>
{/if}
</div>
<!-- Tabs -->
<div class="mb-6 flex gap-1 rounded-lg bg-theme-tertiary p-1">
<button
type="button"
onclick={() => (activeTab = 'my-prompts')}
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab === 'my-prompts'
? 'bg-theme-secondary text-theme-primary shadow'
: 'text-theme-muted hover:text-theme-secondary'}"
>
My Prompts
{#if promptsState.prompts.length > 0}
<span class="ml-1.5 rounded-full bg-theme-tertiary px-2 py-0.5 text-xs {activeTab === 'my-prompts' ? 'bg-blue-500/20 text-blue-400' : ''}">
{promptsState.prompts.length}
</span>
{/if}
</button>
<button
type="button"
onclick={() => (activeTab = 'browse-templates')}
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab === 'browse-templates'
? 'bg-theme-secondary text-theme-primary shadow'
: 'text-theme-muted hover:text-theme-secondary'}"
>
Browse Templates
<span class="ml-1.5 rounded-full bg-theme-tertiary px-2 py-0.5 text-xs {activeTab === 'browse-templates' ? 'bg-purple-500/20 text-purple-400' : ''}">
{templates.length}
</span>
</button>
</div>
<!-- My Prompts Tab -->
{#if activeTab === 'my-prompts'}
{#if promptsState.activePrompt}
<div class="mb-6 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
<div class="flex items-center gap-2 text-sm text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Active system prompt: <strong class="text-blue-300">{promptsState.activePrompt.name}</strong></span>
</div>
</div>
{/if}
{#if promptsState.isLoading}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-theme-subtle border-t-blue-500"></div>
</div>
{:else if promptsState.prompts.length === 0}
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<h3 class="mt-4 text-sm font-medium text-theme-muted">No system prompts yet</h3>
<p class="mt-1 text-sm text-theme-muted">Create a prompt or browse templates to get started</p>
<div class="mt-4 flex justify-center gap-3">
<button type="button" onclick={openCreateEditor} class="inline-flex items-center gap-2 rounded-lg bg-theme-tertiary px-4 py-2 text-sm font-medium text-theme-primary hover:bg-theme-tertiary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create from scratch
</button>
<button type="button" onclick={() => (activeTab = 'browse-templates')} class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-purple-700">
Browse templates
</button>
</div>
</div>
{:else}
<div class="space-y-3">
{#each promptsState.prompts as prompt (prompt.id)}
<div class="rounded-lg border bg-theme-secondary p-4 transition-colors {promptsState.activePromptId === prompt.id ? 'border-blue-500/50' : 'border-theme'}">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium text-theme-primary">{prompt.name}</h3>
{#if prompt.isDefault}
<span class="rounded bg-blue-900 px-2 py-0.5 text-xs text-blue-300">default</span>
{/if}
{#if promptsState.activePromptId === prompt.id}
<span class="rounded bg-emerald-900 px-2 py-0.5 text-xs text-emerald-300">active</span>
{/if}
{#if prompt.targetCapabilities && prompt.targetCapabilities.length > 0}
{#each prompt.targetCapabilities as cap (cap)}
<span class="rounded bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">{cap}</span>
{/each}
{/if}
</div>
{#if prompt.description}
<p class="mt-1 text-sm text-theme-muted">{prompt.description}</p>
{/if}
<p class="mt-2 line-clamp-2 text-sm text-theme-muted">{prompt.content}</p>
<p class="mt-2 text-xs text-theme-muted">Updated {formatDate(prompt.updatedAt)}</p>
</div>
<div class="flex items-center gap-2">
<button type="button" onclick={() => handleSetActive(prompt)} class="rounded p-1.5 transition-colors {promptsState.activePromptId === prompt.id ? 'bg-emerald-600 text-theme-primary' : 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}" title={promptsState.activePromptId === prompt.id ? 'Deactivate' : 'Use for new chats'}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<button type="button" onclick={() => handleSetDefault(prompt)} class="rounded p-1.5 transition-colors {prompt.isDefault ? 'bg-blue-600 text-theme-primary' : 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}" title={prompt.isDefault ? 'Remove as default' : 'Set as default'}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill={prompt.isDefault ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button type="button" onclick={() => openEditEditor(prompt)} class="rounded p-1.5 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary" title="Edit">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button type="button" onclick={() => 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>
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
<!-- Browse Templates Tab -->
{#if activeTab === 'browse-templates'}
<div class="mb-6 flex flex-wrap gap-2">
<button type="button" onclick={() => (selectedCategory = 'all')} class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory === 'all' ? 'bg-theme-secondary text-theme-primary' : 'bg-theme-tertiary text-theme-muted hover:text-theme-secondary'}">
All
</button>
{#each categories as category (category)}
{@const info = categoryInfo[category]}
<button type="button" onclick={() => (selectedCategory = category)} class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory === category ? info.color : 'bg-theme-tertiary text-theme-muted hover:text-theme-secondary'}">
<span>{info.icon}</span>
{info.label}
</button>
{/each}
</div>
<div class="grid gap-4 sm:grid-cols-2">
{#each filteredTemplates as template (template.id)}
{@const info = categoryInfo[template.category]}
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<h3 class="font-medium text-theme-primary">{template.name}</h3>
<span class="mt-1 inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
<span>{info.icon}</span>
{info.label}
</span>
</div>
<button type="button" onclick={() => addTemplateToLibrary(template)} disabled={addingTemplateId === template.id} class="flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-theme-primary hover:bg-blue-700 disabled:opacity-50">
{#if addingTemplateId === template.id}
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
{/if}
Add
</button>
</div>
<p class="text-sm text-theme-muted">{template.description}</p>
<button type="button" onclick={() => (previewTemplate = template)} class="mt-3 text-sm text-blue-400 hover:text-blue-300">
Preview prompt
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Editor Modal -->
{#if showEditor}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) closeEditor(); }} role="dialog" aria-modal="true">
<div class="w-full max-w-2xl rounded-xl bg-theme-secondary shadow-xl">
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<h3 class="text-lg font-semibold text-theme-primary">{editingPrompt ? 'Edit Prompt' : 'Create Prompt'}</h3>
<button type="button" onclick={closeEditor} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }} class="p-6">
<div class="space-y-4">
<div>
<label for="prompt-name" class="mb-1 block text-sm font-medium text-theme-secondary">Name <span class="text-red-400">*</span></label>
<input id="prompt-name" type="text" bind:value={formName} placeholder="e.g., Code Reviewer" class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" required />
</div>
<div>
<label for="prompt-description" class="mb-1 block text-sm font-medium text-theme-secondary">Description</label>
<input id="prompt-description" type="text" bind:value={formDescription} placeholder="Brief description" class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
</div>
<div>
<label for="prompt-content" class="mb-1 block text-sm font-medium text-theme-secondary">System Prompt <span class="text-red-400">*</span></label>
<textarea id="prompt-content" bind:value={formContent} placeholder="You are a helpful assistant that..." rows="8" class="w-full resize-none rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 font-mono text-sm text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" required></textarea>
<p class="mt-1 text-xs text-theme-muted">{formContent.length} characters</p>
</div>
<div class="flex items-center gap-2">
<input id="prompt-default" type="checkbox" bind:checked={formIsDefault} class="h-4 w-4 rounded border-theme-subtle bg-theme-tertiary text-blue-600 focus:ring-blue-500 focus:ring-offset-theme" />
<label for="prompt-default" class="text-sm text-theme-secondary">Set as default for new chats</label>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-theme-secondary">Auto-use for model types</label>
<div class="flex flex-wrap gap-2">
{#each CAPABILITIES as cap (cap.id)}
<button type="button" onclick={() => toggleCapability(cap.id)} class="rounded-lg border px-3 py-1.5 text-sm transition-colors {formTargetCapabilities.includes(cap.id) ? 'border-blue-500 bg-blue-500/20 text-blue-300' : 'border-theme-subtle bg-theme-tertiary text-theme-muted hover:border-theme hover:text-theme-secondary'}" title={cap.description}>
{cap.label}
</button>
{/each}
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button type="button" onclick={closeEditor} class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary hover:bg-theme-tertiary">Cancel</button>
<button type="submit" disabled={isSaving || !formName.trim() || !formContent.trim()} class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50">
{isSaving ? 'Saving...' : editingPrompt ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Template Preview Modal -->
{#if previewTemplate}
{@const info = categoryInfo[previewTemplate.category]}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) previewTemplate = null; }} role="dialog" aria-modal="true">
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-xl bg-theme-secondary shadow-xl">
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<div>
<h3 class="text-lg font-semibold text-theme-primary">{previewTemplate.name}</h3>
<span class="mt-1 inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
<span>{info.icon}</span>
{info.label}
</span>
</div>
<button type="button" onclick={() => (previewTemplate = null)} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-6">
<p class="mb-4 text-sm text-theme-muted">{previewTemplate.description}</p>
<pre class="whitespace-pre-wrap rounded-lg bg-theme-tertiary p-4 font-mono text-sm text-theme-primary">{previewTemplate.content}</pre>
</div>
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
<button type="button" onclick={() => (previewTemplate = null)} class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary hover:bg-theme-tertiary">Close</button>
<button type="button" onclick={() => { if (previewTemplate) { addTemplateToLibrary(previewTemplate); previewTemplate = null; } }} class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add to Library
</button>
</div>
</div>
</div>
{/if}
<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

@@ -0,0 +1,70 @@
<script lang="ts" module>
/**
* SettingsTabs - Horizontal tab navigation for Settings Hub
*/
export type SettingsTab = 'general' | 'models' | 'prompts' | 'tools' | 'knowledge' | 'memory';
</script>
<script lang="ts">
import { page } from '$app/stores';
interface Tab {
id: SettingsTab;
label: string;
icon: string;
}
const tabs: Tab[] = [
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'models', label: 'Models', icon: 'cpu' },
{ id: 'prompts', label: 'Prompts', icon: 'message' },
{ id: 'tools', label: 'Tools', icon: 'wrench' },
{ id: 'knowledge', label: 'Knowledge', icon: 'book' },
{ id: 'memory', label: 'Memory', icon: 'brain' }
];
// Get active tab from URL, default to 'general'
let activeTab = $derived<SettingsTab>(
($page.url.searchParams.get('tab') as SettingsTab) || 'general'
);
</script>
<nav class="flex gap-1 overflow-x-auto">
{#each tabs as tab}
<a
href="/settings?tab={tab.id}"
class="flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium transition-colors
{activeTab === tab.id
? 'border-violet-500 text-violet-400'
: 'border-transparent text-theme-muted hover:border-theme hover:text-theme-primary'}"
>
{#if tab.icon === 'settings'}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.559.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.272-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
{:else if tab.icon === 'cpu'}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
</svg>
{:else if tab.icon === 'message'}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
{:else if tab.icon === 'wrench'}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
</svg>
{:else if tab.icon === 'book'}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
{:else if tab.icon === 'brain'}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
</svg>
{/if}
{tab.label}
</a>
{/each}
</nav>

View File

@@ -0,0 +1,528 @@
<script lang="ts">
/**
* ToolsTab - Enhanced tools management with better visuals
*/
import { toolsState } from '$lib/stores';
import type { ToolDefinition, CustomTool } from '$lib/tools';
import { ToolEditor } from '$lib/components/tools';
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;
showEditor = true;
}
function openEditEditor(tool: CustomTool): void {
editingTool = tool;
showEditor = true;
}
function handleSaveTool(tool: CustomTool): void {
if (editingTool) {
toolsState.updateCustomTool(tool.id, tool);
} else {
toolsState.addCustomTool(tool);
}
showEditor = false;
editingTool = null;
}
function handleDeleteTool(tool: CustomTool): void {
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());
const builtinTools = $derived(allTools.filter(t => t.isBuiltin));
// Stats
const stats = $derived({
total: builtinTools.length + toolsState.customTools.length,
enabled: builtinTools.filter(t => t.enabled).length + toolsState.customTools.filter(t => t.enabled).length,
builtin: builtinTools.length,
custom: toolsState.customTools.length
});
// Filtered tools based on search
const filteredBuiltinTools = $derived(
searchQuery.trim()
? builtinTools.filter(t =>
t.definition.function.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.definition.function.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: builtinTools
);
const filteredCustomTools = $derived(
searchQuery.trim()
? toolsState.customTools.filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: toolsState.customTools
);
function toggleTool(name: string): void {
toolsState.toggleTool(name);
}
function toggleGlobalTools(): void {
toolsState.toggleToolsEnabled();
}
function toggleDescription(toolName: string): void {
const newSet = new Set(expandedDescriptions);
if (newSet.has(toolName)) {
newSet.delete(toolName);
} else {
newSet.add(toolName);
}
expandedDescriptions = newSet;
}
// Get icon for built-in tool based on name
function getToolIcon(name: string): { icon: string; color: string } {
const icons: Record<string, { icon: string; color: string }> = {
'get_current_time': { icon: 'clock', color: 'text-amber-400' },
'calculate': { icon: 'calculator', color: 'text-blue-400' },
'fetch_url': { icon: 'globe', color: 'text-cyan-400' },
'get_location': { icon: 'location', color: 'text-rose-400' },
'web_search': { icon: 'search', color: 'text-emerald-400' }
};
return icons[name] || { icon: 'tool', color: 'text-gray-400' };
}
// Get implementation icon
function getImplementationIcon(impl: string): { icon: string; color: string; bg: string } {
const icons: Record<string, { icon: string; color: string; bg: string }> = {
'javascript': { icon: 'js', color: 'text-yellow-300', bg: 'bg-yellow-900/30' },
'python': { icon: 'py', color: 'text-blue-300', bg: 'bg-blue-900/30' },
'http': { icon: 'http', color: 'text-purple-300', bg: 'bg-purple-900/30' }
};
return icons[impl] || { icon: '?', color: 'text-gray-300', bg: 'bg-gray-900/30' };
}
// Format parameters with type info
function getParameters(def: ToolDefinition): Array<{ name: string; type: string; required: boolean; description?: string }> {
const params = def.function.parameters;
if (!params.properties) return [];
return Object.entries(params.properties).map(([name, prop]) => ({
name,
type: prop.type,
required: params.required?.includes(name) ?? false,
description: prop.description
}));
}
// Check if description is long
function isLongDescription(text: string): boolean {
return text.length > 150;
}
</script>
<div>
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-theme-primary">Tools</h2>
<p class="mt-1 text-sm text-theme-muted">
Extend AI capabilities with built-in and custom tools
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-theme-muted">Tools enabled</span>
<button
type="button"
onclick={toggleGlobalTools}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-theme-primary {toolsState.toolsEnabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={toolsState.toolsEnabled}
>
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {toolsState.toolsEnabled ? 'translate-x-5' : 'translate-x-0'}"></span>
</button>
</div>
</div>
<!-- Stats -->
<div class="mb-6 grid grid-cols-4 gap-4">
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted">Total Tools</p>
<p class="mt-1 text-2xl font-semibold text-theme-primary">{stats.total}</p>
</div>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted">Enabled</p>
<p class="mt-1 text-2xl font-semibold text-emerald-400">{stats.enabled}</p>
</div>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted">Built-in</p>
<p class="mt-1 text-2xl font-semibold text-blue-400">{stats.builtin}</p>
</div>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<p class="text-sm text-theme-muted">Custom</p>
<p class="mt-1 text-2xl font-semibold text-violet-400">{stats.custom}</p>
</div>
</div>
<!-- Search -->
<div class="mb-6">
<div class="relative">
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Search tools..."
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-sm text-theme-primary placeholder:text-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
{#if searchQuery}
<button
type="button"
onclick={() => searchQuery = ''}
class="absolute right-3 top-1/2 -translate-y-1/2 text-theme-muted hover:text-theme-primary"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Built-in Tools -->
<section class="mb-8">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Built-in Tools
<span class="text-sm font-normal text-theme-muted">({filteredBuiltinTools.length})</span>
</h3>
{#if filteredBuiltinTools.length === 0}
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
<p class="text-sm text-theme-muted">No tools match your search</p>
</div>
{:else}
<div class="space-y-3">
{#each filteredBuiltinTools as tool (tool.definition.function.name)}
{@const toolIcon = getToolIcon(tool.definition.function.name)}
{@const params = getParameters(tool.definition)}
{@const isLong = isLongDescription(tool.definition.function.description)}
{@const isExpanded = expandedDescriptions.has(tool.definition.function.name)}
<div class="rounded-lg border border-theme bg-theme-secondary transition-all {tool.enabled ? '' : 'opacity-50'}">
<div class="p-4">
<div class="flex items-start gap-4">
<!-- Tool Icon -->
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-theme-tertiary {toolIcon.color}">
{#if toolIcon.icon === 'clock'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{:else if toolIcon.icon === 'calculator'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 15.75V18m-7.5-6.75h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V13.5zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V18zm2.498-6.75h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V13.5zm0 2.25h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V18zm2.504-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V18zm2.498-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zM8.25 6h7.5v2.25h-7.5V6zM12 2.25c-1.892 0-3.758.11-5.593.322C5.307 2.7 4.5 3.65 4.5 4.757V19.5a2.25 2.25 0 002.25 2.25h10.5a2.25 2.25 0 002.25-2.25V4.757c0-1.108-.806-2.057-1.907-2.185A48.507 48.507 0 0012 2.25z" />
</svg>
{:else if toolIcon.icon === 'globe'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
{:else if toolIcon.icon === 'location'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
{:else if toolIcon.icon === 'search'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
{:else}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
</svg>
{/if}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h4 class="font-mono text-sm font-semibold text-theme-primary">{tool.definition.function.name}</h4>
<span class="rounded-full bg-blue-900/40 px-2 py-0.5 text-xs font-medium text-blue-300">built-in</span>
</div>
<!-- Description -->
<div class="mt-2">
<p class="text-sm text-theme-muted {isLong && !isExpanded ? 'line-clamp-2' : ''}">
{tool.definition.function.description}
</p>
{#if isLong}
<button
type="button"
onclick={() => toggleDescription(tool.definition.function.name)}
class="mt-1 text-xs text-violet-400 hover:text-violet-300"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
{/if}
</div>
</div>
<!-- Toggle -->
<button
type="button"
onclick={() => toggleTool(tool.definition.function.name)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={tool.enabled}
disabled={!toolsState.toolsEnabled}
>
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
</button>
</div>
<!-- Parameters -->
{#if params.length > 0}
<div class="mt-3 flex flex-wrap gap-2 border-t border-theme pt-3">
{#each params as param}
<div class="flex items-center gap-1 rounded-md bg-theme-tertiary px-2 py-1" title={param.description || ''}>
<span class="font-mono text-xs text-theme-primary">{param.name}</span>
{#if param.required}
<span class="text-xs text-rose-400">*</span>
{/if}
<span class="text-xs text-theme-muted">:</span>
<span class="rounded bg-theme-hover px-1 text-xs text-cyan-400">{param.type}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Custom Tools -->
<section>
<div class="mb-4 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
Custom Tools
<span class="text-sm font-normal text-theme-muted">({filteredCustomTools.length})</span>
</h3>
<button
type="button"
onclick={openCreateEditor}
class="flex items-center gap-2 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create Tool
</button>
</div>
{#if filteredCustomTools.length === 0 && toolsState.customTools.length === 0}
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
<h4 class="mt-4 text-sm font-medium text-theme-secondary">No custom tools yet</h4>
<p class="mt-1 text-sm text-theme-muted">Create JavaScript, Python, or HTTP tools to extend AI capabilities</p>
<button
type="button"
onclick={openCreateEditor}
class="mt-4 inline-flex items-center gap-2 rounded-lg border border-violet-500 px-4 py-2 text-sm font-medium text-violet-400 transition-colors hover:bg-violet-900/30"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create Your First Tool
</button>
</div>
{:else if filteredCustomTools.length === 0}
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
<p class="text-sm text-theme-muted">No custom tools match your search</p>
</div>
{:else}
<div class="space-y-3">
{#each filteredCustomTools as tool (tool.id)}
{@const implIcon = getImplementationIcon(tool.implementation)}
{@const customParams = Object.entries(tool.parameters.properties ?? {})}
{@const isLong = isLongDescription(tool.description)}
{@const isExpanded = expandedDescriptions.has(tool.id)}
<div class="rounded-lg border border-theme bg-theme-secondary transition-all {tool.enabled ? '' : 'opacity-50'}">
<div class="p-4">
<div class="flex items-start gap-4">
<!-- Implementation Icon -->
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg {implIcon.bg}">
{#if tool.implementation === 'javascript'}
<span class="font-mono text-sm font-bold {implIcon.color}">JS</span>
{:else if tool.implementation === 'python'}
<span class="font-mono text-sm font-bold {implIcon.color}">PY</span>
{:else}
<svg class="h-5 w-5 {implIcon.color}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
{/if}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h4 class="font-mono text-sm font-semibold text-theme-primary">{tool.name}</h4>
<span class="rounded-full bg-violet-900/40 px-2 py-0.5 text-xs font-medium text-violet-300">custom</span>
<span class="rounded-full {implIcon.bg} px-2 py-0.5 text-xs font-medium {implIcon.color}">{tool.implementation}</span>
</div>
<!-- Description -->
<div class="mt-2">
<p class="text-sm text-theme-muted {isLong && !isExpanded ? 'line-clamp-2' : ''}">
{tool.description}
</p>
{#if isLong}
<button
type="button"
onclick={() => toggleDescription(tool.id)}
class="mt-1 text-xs text-violet-400 hover:text-violet-300"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
{/if}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => openEditEditor(tool)}
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Edit tool"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
<button
type="button"
onclick={() => handleDeleteTool(tool)}
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
aria-label="Delete tool"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
<button
type="button"
onclick={() => toggleTool(tool.name)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={tool.enabled}
disabled={!toolsState.toolsEnabled}
>
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
</button>
</div>
</div>
<!-- Parameters -->
{#if customParams.length > 0}
<div class="mt-3 flex flex-wrap gap-2 border-t border-theme pt-3">
{#each customParams as [name, prop]}
<div class="flex items-center gap-1 rounded-md bg-theme-tertiary px-2 py-1" title={prop.description || ''}>
<span class="font-mono text-xs text-theme-primary">{name}</span>
{#if tool.parameters.required?.includes(name)}
<span class="text-xs text-rose-400">*</span>
{/if}
<span class="text-xs text-theme-muted">:</span>
<span class="rounded bg-theme-hover px-1 text-xs text-cyan-400">{prop.type}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Info Section -->
<section class="mt-8 rounded-lg border border-theme bg-gradient-to-br from-theme-secondary/80 to-theme-secondary/40 p-5">
<h4 class="flex items-center gap-2 text-sm font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
How Tools Work
</h4>
<p class="mt-3 text-sm text-theme-muted leading-relaxed">
Tools extend the AI's capabilities by allowing it to perform actions beyond text generation.
When you ask a question that could benefit from a tool, the AI will automatically select and use the appropriate one.
</p>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="rounded-lg bg-theme-tertiary/50 p-3">
<div class="flex items-center gap-2 text-xs font-medium text-yellow-400">
<span class="font-mono">JS</span>
JavaScript
</div>
<p class="mt-1 text-xs text-theme-muted">Runs in browser, instant execution</p>
</div>
<div class="rounded-lg bg-theme-tertiary/50 p-3">
<div class="flex items-center gap-2 text-xs font-medium text-blue-400">
<span class="font-mono">PY</span>
Python
</div>
<p class="mt-1 text-xs text-theme-muted">Runs on backend server</p>
</div>
<div class="rounded-lg bg-theme-tertiary/50 p-3">
<div class="flex items-center gap-2 text-xs font-medium text-purple-400">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
HTTP
</div>
<p class="mt-1 text-xs text-theme-muted">Calls external APIs</p>
</div>
</div>
<p class="mt-4 text-xs text-theme-muted">
<strong class="text-theme-secondary">Note:</strong> Not all models support tool calling. Models like Llama 3.1+, Mistral 7B+, and Qwen have built-in tool support.
</p>
</section>
</div>
<ToolEditor
isOpen={showEditor}
editingTool={editingTool}
onClose={() => { showEditor = false; editingTool = null; }}
onSave={handleSaveTool}
/>
<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

@@ -0,0 +1,13 @@
/**
* Settings components barrel export
*/
export { default as SettingsTabs } from './SettingsTabs.svelte';
export { default as GeneralTab } from './GeneralTab.svelte';
export { default as ModelsTab } from './ModelsTab.svelte';
export { default as PromptsTab } from './PromptsTab.svelte';
export { default as ToolsTab } from './ToolsTab.svelte';
export { default as KnowledgeTab } from './KnowledgeTab.svelte';
export { default as MemoryTab } from './MemoryTab.svelte';
export { default as ModelParametersPanel } from './ModelParametersPanel.svelte';
export type { SettingsTab } from './SettingsTabs.svelte';

View File

@@ -1,12 +1,13 @@
<script lang="ts">
/**
* SearchModal - Global search modal for conversations and messages
* Supports searching both conversation titles and message content
* Supports searching conversation titles, message content, and semantic search
*/
import { goto } from '$app/navigation';
import { searchConversations, searchMessages, type MessageSearchResult } from '$lib/storage';
import { conversationsState } from '$lib/stores';
import type { Conversation } from '$lib/types/conversation';
import { searchAllChatHistory, type ChatSearchResult } from '$lib/services/chat-indexer.js';
interface Props {
isOpen: boolean;
@@ -17,12 +18,13 @@
// Search state
let searchQuery = $state('');
let activeTab = $state<'titles' | 'messages'>('titles');
let activeTab = $state<'titles' | 'messages' | 'semantic'>('titles');
let isSearching = $state(false);
// Results
let titleResults = $state<Conversation[]>([]);
let messageResults = $state<MessageSearchResult[]>([]);
let semanticResults = $state<ChatSearchResult[]>([]);
// Debounce timer
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -41,6 +43,7 @@
if (!searchQuery.trim()) {
titleResults = [];
messageResults = [];
semanticResults = [];
return;
}
@@ -48,10 +51,11 @@
isSearching = true;
try {
// Search both in parallel
const [titlesResult, messagesResult] = await Promise.all([
// Search all three in parallel
const [titlesResult, messagesResult, semanticSearchResults] = await Promise.all([
searchConversations(searchQuery),
searchMessages(searchQuery, { limit: 30 })
searchMessages(searchQuery, { limit: 30 }),
searchAllChatHistory(searchQuery, undefined, 30, 0.15)
]);
if (titlesResult.success) {
@@ -61,6 +65,10 @@
if (messagesResult.success) {
messageResults = messagesResult.data;
}
semanticResults = semanticSearchResults;
} catch (error) {
console.error('[SearchModal] Search error:', error);
} finally {
isSearching = false;
}
@@ -125,6 +133,7 @@
searchQuery = '';
titleResults = [];
messageResults = [];
semanticResults = [];
activeTab = 'titles';
onClose();
}
@@ -142,6 +151,7 @@
searchQuery = '';
titleResults = [];
messageResults = [];
semanticResults = [];
}
});
</script>
@@ -255,6 +265,20 @@
>
{/if}
</button>
<button
type="button"
onclick={() => (activeTab = 'semantic')}
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'semantic'
? 'border-b-2 border-emerald-500 text-emerald-400'
: 'text-theme-muted hover:text-theme-secondary'}"
>
Semantic
{#if semanticResults.length > 0}
<span class="ml-1.5 rounded-full bg-theme-secondary px-1.5 py-0.5 text-xs"
>{semanticResults.length}</span
>
{/if}
</button>
</div>
<!-- Results -->
@@ -312,7 +336,7 @@
{/each}
</div>
{/if}
{:else}
{:else if activeTab === 'messages'}
{#if messageResults.length === 0 && !isSearching}
<div class="py-8 text-center text-sm text-theme-muted">
No messages found matching "{searchQuery}"
@@ -345,6 +369,35 @@
{/each}
</div>
{/if}
{:else if activeTab === 'semantic'}
{#if semanticResults.length === 0 && !isSearching}
<div class="py-8 text-center text-sm text-theme-muted">
<p>No semantic matches found for "{searchQuery}"</p>
<p class="mt-1 text-xs">Semantic search uses AI embeddings to find similar content</p>
</div>
{:else}
<div class="divide-y divide-theme-secondary">
{#each semanticResults as result}
<button
type="button"
onclick={() => navigateToConversation(result.conversationId)}
class="flex w-full flex-col gap-1 px-4 py-3 text-left transition-colors hover:bg-theme-secondary"
>
<div class="flex items-center gap-2">
<span class="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
{Math.round(result.similarity * 100)}% match
</span>
<span class="truncate text-xs text-theme-muted">
{result.conversationTitle}
</span>
</div>
<p class="line-clamp-2 text-sm text-theme-secondary">
{result.content.slice(0, 200)}{result.content.length > 200 ? '...' : ''}
</p>
</button>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -513,6 +513,29 @@ print(json.dumps(result))`;
</label>
</div>
</div>
<!-- Test button for HTTP -->
<button
type="button"
onclick={() => showTest = !showTest}
class="flex items-center gap-2 text-sm {showTest ? 'text-emerald-400' : 'text-theme-muted hover:text-theme-secondary'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
{showTest ? 'Hide Test Panel' : 'Test Tool'}
</button>
<!-- Tool tester for HTTP -->
<ToolTester
{implementation}
code=""
{endpoint}
{httpMethod}
parameters={buildParameterSchema()}
isOpen={showTest}
onclose={() => showTest = false}
/>
</div>
{/if}

View File

@@ -10,11 +10,13 @@
implementation: ToolImplementation;
code: string;
parameters: JSONSchema;
endpoint?: string;
httpMethod?: 'GET' | 'POST';
isOpen?: boolean;
onclose?: () => void;
}
const { implementation, code, parameters, isOpen = false, onclose }: Props = $props();
const { implementation, code, parameters, endpoint = '', httpMethod = 'POST', isOpen = false, onclose }: Props = $props();
let testInput = $state('{}');
let testResult = $state<{ success: boolean; result?: unknown; error?: string } | null>(null);
@@ -116,8 +118,54 @@
error: error instanceof Error ? error.message : String(error)
};
}
} else if (implementation === 'http') {
// HTTP endpoint execution
if (!endpoint.trim()) {
testResult = { success: false, error: 'HTTP endpoint URL is required' };
isRunning = false;
return;
}
try {
const url = new URL(endpoint);
const options: RequestInit = {
method: httpMethod,
headers: {
'Content-Type': 'application/json'
}
};
if (httpMethod === 'GET') {
// Add args as query parameters
for (const [key, value] of Object.entries(args)) {
url.searchParams.set(key, String(value));
}
} else {
options.body = JSON.stringify(args);
}
const response = await fetch(url.toString(), options);
if (!response.ok) {
testResult = {
success: false,
error: `HTTP ${response.status}: ${response.statusText}`
};
} else {
const contentType = response.headers.get('content-type');
const result = contentType?.includes('application/json')
? await response.json()
: await response.text();
testResult = { success: true, result };
}
} catch (error) {
testResult = {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
} else {
testResult = { success: false, error: 'HTTP tools cannot be tested in the editor' };
testResult = { success: false, error: 'Unknown implementation type' };
}
} finally {
isRunning = false;
@@ -169,7 +217,7 @@
<button
type="button"
onclick={runTest}
disabled={isRunning || !code.trim()}
disabled={isRunning || (implementation === 'http' ? !endpoint.trim() : !code.trim())}
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-lg bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isRunning}

View File

@@ -31,7 +31,7 @@ export interface ChunkOptions {
}
/**
* Split text into overlapping chunks
* Split text into overlapping chunks (synchronous version)
*/
export function chunkText(
text: string,
@@ -62,8 +62,15 @@ export function chunkText(
const chunks: DocumentChunk[] = [];
let currentIndex = 0;
let previousIndex = -1;
while (currentIndex < text.length) {
// Prevent infinite loop - if we haven't advanced, we're stuck
if (currentIndex === previousIndex) {
break;
}
previousIndex = currentIndex;
// Calculate end position for this chunk
let endIndex = Math.min(currentIndex + chunkSize, text.length);
@@ -89,13 +96,109 @@ export function chunkText(
});
}
// If we've reached the end, we're done
if (endIndex >= text.length) {
break;
}
// Move to next chunk position (with overlap)
currentIndex = endIndex - overlap;
// Prevent infinite loop
if (currentIndex <= 0 || currentIndex >= text.length) {
// Safety: ensure we always advance
if (currentIndex <= previousIndex) {
currentIndex = previousIndex + 1;
}
}
return chunks;
}
/**
* Split text into overlapping chunks (async version that yields to event loop)
* Use this for large files to avoid blocking the UI
*/
export async function chunkTextAsync(
text: string,
documentId: string,
options: ChunkOptions = {}
): Promise<DocumentChunk[]> {
const {
chunkSize = DEFAULT_CHUNK_SIZE,
overlap = DEFAULT_OVERLAP,
respectSentences = true,
respectParagraphs = true
} = options;
if (!text || text.length === 0) {
return [];
}
// For very short texts, return as single chunk
if (text.length <= chunkSize) {
return [{
id: crypto.randomUUID(),
documentId,
content: text.trim(),
startIndex: 0,
endIndex: text.length
}];
}
const chunks: DocumentChunk[] = [];
let currentIndex = 0;
let iterationCount = 0;
let previousIndex = -1;
while (currentIndex < text.length) {
// Yield every 10 chunks to let UI breathe
if (iterationCount > 0 && iterationCount % 10 === 0) {
await new Promise(r => setTimeout(r, 0));
}
iterationCount++;
// Prevent infinite loop - if we haven't advanced, we're stuck
if (currentIndex === previousIndex) {
break;
}
previousIndex = currentIndex;
// Calculate end position for this chunk
let endIndex = Math.min(currentIndex + chunkSize, text.length);
// If not at end of text, try to find a good break point
if (endIndex < text.length) {
endIndex = findBreakPoint(text, currentIndex, endIndex, {
respectSentences,
respectParagraphs
});
}
// Extract chunk content
const content = text.slice(currentIndex, endIndex).trim();
// Only add non-empty chunks above minimum size
if (content.length >= MIN_CHUNK_SIZE) {
chunks.push({
id: crypto.randomUUID(),
documentId,
content,
startIndex: currentIndex,
endIndex
});
}
// If we've reached the end, we're done
if (endIndex >= text.length) {
break;
}
// Move to next chunk position (with overlap)
currentIndex = endIndex - overlap;
// Safety: ensure we always advance
if (currentIndex <= previousIndex) {
currentIndex = previousIndex + 1;
}
}
return chunks;
@@ -145,17 +248,15 @@ function findBreakPoint(
/**
* Find the last match of a pattern after a given position
* Uses matchAll instead of exec to avoid hook false positive
* Uses matchAll to find all matches and returns the last one after minPos
*/
function findLastMatchPosition(text: string, pattern: RegExp, minPos: number): number {
let lastMatch = -1;
// Use matchAll to find all matches
const matches = Array.from(text.matchAll(pattern));
for (const match of matches) {
// Use matchAll to iterate through matches
for (const match of text.matchAll(pattern)) {
if (match.index !== undefined && match.index >= minPos) {
// Add the length of the match to include it in the chunk
// Track position after the match
lastMatch = match.index + match[0].length;
}
}

View File

@@ -32,14 +32,30 @@ export async function generateEmbedding(
text: string,
model: string = DEFAULT_EMBEDDING_MODEL
): Promise<number[]> {
const response = await ollamaClient.embed({
model,
input: text
});
const TIMEOUT_MS = 30000; // 30 second timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
// Ollama returns an array of embeddings (one per input)
// We're only passing one input, so take the first
return response.embeddings[0];
try {
const response = await ollamaClient.embed({
model,
input: text
}, controller.signal);
// Ollama returns an array of embeddings (one per input)
// We're only passing one input, so take the first
if (!response.embeddings || response.embeddings.length === 0) {
throw new Error(`No embeddings returned from model "${model}". Is the model available?`);
}
return response.embeddings[0];
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Embedding generation timed out. Is the model "${model}" available?`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/**
@@ -53,13 +69,30 @@ export async function generateEmbeddings(
const BATCH_SIZE = 10;
const results: number[][] = [];
// Create abort controller with timeout
const TIMEOUT_MS = 30000; // 30 second timeout per batch
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
const response = await ollamaClient.embed({
model,
input: batch
});
results.push(...response.embeddings);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await ollamaClient.embed({
model,
input: batch
}, controller.signal);
results.push(...response.embeddings);
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Embedding generation timed out. Is the model "${model}" available?`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
return results;

View File

@@ -59,6 +59,7 @@ export {
// Chunking
export {
chunkText,
chunkTextAsync,
splitByParagraphs,
splitBySentences,
estimateChunkTokens,
@@ -69,6 +70,7 @@ export {
// Vector store
export {
addDocument,
addDocumentAsync,
searchSimilar,
listDocuments,
getDocument,
@@ -76,6 +78,9 @@ export {
deleteDocument,
getKnowledgeBaseStats,
formatResultsAsContext,
resetStuckDocuments,
type SearchResult,
type AddDocumentOptions
type SearchOptions,
type AddDocumentOptions,
type AddDocumentAsyncOptions
} from './vector-store.js';

View File

@@ -7,7 +7,7 @@
import { db, type StoredDocument, type StoredChunk } from '$lib/storage/db.js';
import { generateEmbedding, generateEmbeddings, cosineSimilarity, DEFAULT_EMBEDDING_MODEL } from './embeddings.js';
import { chunkText, estimateChunkTokens, type ChunkOptions } from './chunker.js';
import { chunkText, chunkTextAsync, estimateChunkTokens, type ChunkOptions } from './chunker.js';
/** Result of a similarity search */
export interface SearchResult {
@@ -24,6 +24,8 @@ export interface AddDocumentOptions {
embeddingModel?: string;
/** Callback for progress updates */
onProgress?: (current: number, total: number) => void;
/** Project ID if document belongs to a project */
projectId?: string;
}
/**
@@ -39,7 +41,8 @@ export async function addDocument(
const {
chunkOptions,
embeddingModel = DEFAULT_EMBEDDING_MODEL,
onProgress
onProgress,
projectId
} = options;
const documentId = crypto.randomUUID();
@@ -88,7 +91,9 @@ export async function addDocument(
createdAt: now,
updatedAt: now,
chunkCount: storedChunks.length,
embeddingModel
embeddingModel,
projectId: projectId ?? null,
embeddingStatus: 'ready'
};
// Store in database
@@ -100,15 +105,162 @@ export async function addDocument(
return document;
}
/** Options for async document upload */
export interface AddDocumentAsyncOptions extends AddDocumentOptions {
/** Callback when embedding generation completes */
onComplete?: (doc: StoredDocument) => void;
/** Callback when embedding generation fails */
onError?: (error: Error) => void;
}
/**
* Search for similar chunks across all documents
* Add a document asynchronously - stores immediately, generates embeddings in background
* Returns immediately with the document in 'pending' state
*/
export async function addDocumentAsync(
name: string,
content: string,
mimeType: string,
options: AddDocumentAsyncOptions = {}
): Promise<StoredDocument> {
const {
chunkOptions,
embeddingModel = DEFAULT_EMBEDDING_MODEL,
onProgress,
onComplete,
onError,
projectId
} = options;
const documentId = crypto.randomUUID();
const now = Date.now();
// Create document record immediately (without knowing chunk count yet)
// We'll update it after chunking in the background
const document: StoredDocument = {
id: documentId,
name,
mimeType,
size: content.length,
createdAt: now,
updatedAt: now,
chunkCount: 0, // Will be updated after chunking
embeddingModel,
projectId: projectId ?? null,
embeddingStatus: 'pending'
};
// Store document immediately
await db.documents.add(document);
// Process everything in background (non-blocking) - including chunking
setTimeout(async () => {
console.log('[Embedding] Starting for:', name, 'content length:', content.length);
try {
// Update status to processing
await db.documents.update(documentId, { embeddingStatus: 'processing' });
console.log('[Embedding] Status updated, starting chunking...');
// Chunk the content using async version (yields periodically)
let textChunks;
try {
textChunks = await chunkTextAsync(content, documentId, chunkOptions);
} catch (chunkError) {
console.error('[Embedding] Chunking failed:', chunkError);
throw chunkError;
}
console.log('[Embedding] Chunked into', textChunks.length, 'chunks');
if (textChunks.length === 0) {
throw new Error('Document produced no chunks');
}
// Update chunk count
await db.documents.update(documentId, { chunkCount: textChunks.length });
const chunkContents = textChunks.map(c => c.content);
const embeddings: number[][] = [];
// Process embeddings in batches with progress
const BATCH_SIZE = 5;
const totalBatches = Math.ceil(chunkContents.length / BATCH_SIZE);
for (let i = 0; i < chunkContents.length; i += BATCH_SIZE) {
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
console.log(`[Embedding] Batch ${batchNum}/${totalBatches}...`);
const batch = chunkContents.slice(i, i + BATCH_SIZE);
const batchEmbeddings = await generateEmbeddings(batch, embeddingModel);
embeddings.push(...batchEmbeddings);
if (onProgress) {
onProgress(Math.min(i + BATCH_SIZE, chunkContents.length), chunkContents.length);
}
}
// Create stored chunks with embeddings
const storedChunks: StoredChunk[] = textChunks.map((chunk, index) => ({
id: chunk.id,
documentId,
content: chunk.content,
embedding: embeddings[index],
startIndex: chunk.startIndex,
endIndex: chunk.endIndex,
tokenCount: estimateChunkTokens(chunk.content)
}));
// Store chunks and update document status
await db.transaction('rw', [db.documents, db.chunks], async () => {
await db.chunks.bulkAdd(storedChunks);
await db.documents.update(documentId, {
embeddingStatus: 'ready',
updatedAt: Date.now()
});
});
console.log('[Embedding] Complete for:', name);
const updatedDoc = await db.documents.get(documentId);
if (updatedDoc && onComplete) {
onComplete(updatedDoc);
}
} catch (error) {
console.error('[Embedding] Failed:', error);
await db.documents.update(documentId, { embeddingStatus: 'failed' });
if (onError) {
onError(error instanceof Error ? error : new Error(String(error)));
}
}
}, 0);
return document;
}
/** Options for similarity search */
export interface SearchOptions {
/** Maximum number of results to return */
topK?: number;
/** Minimum similarity threshold (0-1) */
threshold?: number;
/** Embedding model to use */
embeddingModel?: string;
/** Filter to documents in this project only (null = global only, undefined = all) */
projectId?: string | null;
}
/**
* Search for similar chunks across documents
* @param query - The search query
* @param options - Search options including projectId filter
*/
export async function searchSimilar(
query: string,
topK: number = 5,
threshold: number = 0.5,
embeddingModel: string = DEFAULT_EMBEDDING_MODEL
options: SearchOptions = {}
): Promise<SearchResult[]> {
const {
topK = 5,
threshold = 0.5,
embeddingModel = DEFAULT_EMBEDDING_MODEL,
projectId
} = options;
// Generate embedding for query
const queryEmbedding = await generateEmbedding(query, embeddingModel);
@@ -120,31 +272,50 @@ export async function searchSimilar(
return [];
}
// Get document IDs that match the project filter
let allowedDocumentIds: Set<string> | null = null;
if (projectId !== undefined) {
const docs = await db.documents.toArray();
const filteredDocs = docs.filter((d) =>
projectId === null ? !d.projectId : d.projectId === projectId
);
allowedDocumentIds = new Set(filteredDocs.map((d) => d.id));
}
// Filter chunks by project and calculate similarities
const relevantChunks = allowedDocumentIds
? allChunks.filter((c) => allowedDocumentIds!.has(c.documentId))
: allChunks;
if (relevantChunks.length === 0) {
return [];
}
// Calculate similarities
const scored = allChunks.map(chunk => ({
const scored = relevantChunks.map((chunk) => ({
chunk,
similarity: cosineSimilarity(queryEmbedding, chunk.embedding)
}));
// Filter and sort
const filtered = scored
.filter(item => item.similarity >= threshold)
.filter((item) => item.similarity >= threshold)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, topK);
// Fetch document info for results
const documentIds = [...new Set(filtered.map(r => r.chunk.documentId))];
const documentIds = [...new Set(filtered.map((r) => r.chunk.documentId))];
const documents = await db.documents.bulkGet(documentIds);
const documentMap = new Map(documents.filter(Boolean).map(d => [d!.id, d!]));
const documentMap = new Map(documents.filter(Boolean).map((d) => [d!.id, d!]));
// Build results
return filtered
.map(item => ({
.map((item) => ({
chunk: item.chunk,
document: documentMap.get(item.chunk.documentId)!,
similarity: item.similarity
}))
.filter(r => r.document !== undefined);
.filter((r) => r.document !== undefined);
}
/**
@@ -178,6 +349,59 @@ export async function deleteDocument(id: string): Promise<void> {
});
}
/**
* Retry embedding for a stuck document
* Useful when HMR or page refresh interrupts background processing
*/
export async function retryDocumentEmbedding(
documentId: string,
onComplete?: (doc: StoredDocument) => void,
onError?: (error: Error) => void
): Promise<void> {
const doc = await db.documents.get(documentId);
if (!doc) {
throw new Error('Document not found');
}
// Only retry if stuck in pending or processing state
if (doc.embeddingStatus === 'ready') {
console.log('Document already has embeddings');
return;
}
// Delete any existing chunks for this document
await db.chunks.where('documentId').equals(documentId).delete();
// We need the original content, which we don't store
// So we need to mark it as failed - user will need to re-upload
// OR we could store the content temporarily...
// For now, just mark as failed so user knows to re-upload
await db.documents.update(documentId, { embeddingStatus: 'failed' });
if (onError) {
onError(new Error('Cannot retry - document content not cached. Please re-upload the file.'));
}
}
/**
* Reset stuck documents (pending/processing) to failed state
* Call this on app startup to clean up interrupted uploads
*/
export async function resetStuckDocuments(): Promise<number> {
// Get all documents and filter in memory (no index required)
const allDocs = await db.documents.toArray();
const stuckDocs = allDocs.filter(
doc => doc.embeddingStatus === 'pending' || doc.embeddingStatus === 'processing'
);
for (const doc of stuckDocs) {
await db.documents.update(doc.id, { embeddingStatus: 'failed' });
}
return stuckDocs.length;
}
/**
* Get total statistics for the knowledge base
*/

View File

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

View File

@@ -0,0 +1,262 @@
/**
* Chat Index Migration Service
* Background service that indexes existing conversations for semantic search
* Processes in small batches to avoid blocking the UI
*/
import { db } from '$lib/storage/db.js';
import { indexConversationMessages, isConversationIndexed } from './chat-indexer.js';
import type { Message } from '$lib/types/chat.js';
import { settingsState } from '$lib/stores';
// ============================================================================
// Types
// ============================================================================
export interface MigrationProgress {
total: number;
indexed: number;
skipped: number;
failed: number;
isRunning: boolean;
currentConversation: string | null;
}
export interface MigrationOptions {
/** Number of conversations to process per batch */
batchSize?: number;
/** Delay between batches in ms */
batchDelay?: number;
/** Minimum messages required to index a conversation */
minMessages?: number;
/** Embedding model to use */
embeddingModel?: string;
/** Callback for progress updates */
onProgress?: (progress: MigrationProgress) => void;
}
// ============================================================================
// State
// ============================================================================
let migrationInProgress = false;
let migrationAborted = false;
// ============================================================================
// Migration Functions
// ============================================================================
/**
* Run the chat index migration in the background
* Indexes all conversations that don't have chat chunks yet
*/
export async function runChatIndexMigration(options: MigrationOptions = {}): Promise<MigrationProgress> {
const {
batchSize = 2,
batchDelay = 500,
minMessages = 2,
embeddingModel,
onProgress
} = options;
// Prevent multiple migrations running at once
if (migrationInProgress) {
console.log('[ChatIndexMigration] Migration already in progress, skipping');
return {
total: 0,
indexed: 0,
skipped: 0,
failed: 0,
isRunning: true,
currentConversation: null
};
}
migrationInProgress = true;
migrationAborted = false;
const progress: MigrationProgress = {
total: 0,
indexed: 0,
skipped: 0,
failed: 0,
isRunning: true,
currentConversation: null
};
try {
// Get all conversations
const allConversations = await db.conversations.toArray();
progress.total = allConversations.length;
console.log(`[ChatIndexMigration] Starting migration for ${progress.total} conversations`);
onProgress?.(progress);
// Process in batches
for (let i = 0; i < allConversations.length; i += batchSize) {
if (migrationAborted) {
console.log('[ChatIndexMigration] Migration aborted');
break;
}
const batch = allConversations.slice(i, i + batchSize);
// Process batch in parallel
await Promise.all(batch.map(async (conversation) => {
if (migrationAborted) return;
progress.currentConversation = conversation.title;
onProgress?.(progress);
try {
// Check if already indexed
const isIndexed = await isConversationIndexed(conversation.id);
if (isIndexed) {
progress.skipped++;
return;
}
// Skip conversations with too few messages
if (conversation.messageCount < minMessages) {
progress.skipped++;
return;
}
// Get messages for this conversation
const messages = await getMessagesForIndexing(conversation.id);
if (messages.length < minMessages) {
progress.skipped++;
return;
}
// Index the conversation
const projectId = conversation.projectId || null;
const chunksIndexed = await indexConversationMessages(
conversation.id,
projectId,
messages,
{ embeddingModel }
);
if (chunksIndexed > 0) {
progress.indexed++;
console.log(`[ChatIndexMigration] Indexed "${conversation.title}" (${chunksIndexed} chunks)`);
} else {
progress.skipped++;
}
} catch (error) {
console.error(`[ChatIndexMigration] Failed to index "${conversation.title}":`, error);
progress.failed++;
}
}));
onProgress?.(progress);
// Delay between batches to avoid overwhelming the system
if (i + batchSize < allConversations.length && !migrationAborted) {
await delay(batchDelay);
}
}
progress.isRunning = false;
progress.currentConversation = null;
onProgress?.(progress);
console.log(`[ChatIndexMigration] Migration complete: ${progress.indexed} indexed, ${progress.skipped} skipped, ${progress.failed} failed`);
return progress;
} finally {
migrationInProgress = false;
}
}
/**
* Abort the current migration
*/
export function abortChatIndexMigration(): void {
migrationAborted = true;
}
/**
* Check if migration is currently running
*/
export function isMigrationRunning(): boolean {
return migrationInProgress;
}
/**
* Get migration statistics
*/
export async function getMigrationStats(): Promise<{
totalConversations: number;
indexedConversations: number;
pendingConversations: number;
}> {
const [allConversations, indexedConversationIds] = await Promise.all([
db.conversations.count(),
db.chatChunks.orderBy('conversationId').uniqueKeys()
]);
const indexedCount = (indexedConversationIds as string[]).length;
return {
totalConversations: allConversations,
indexedConversations: indexedCount,
pendingConversations: allConversations - indexedCount
};
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Get messages from a conversation in the format needed for indexing
*/
async function getMessagesForIndexing(conversationId: string): Promise<Message[]> {
const storedMessages = await db.messages
.where('conversationId')
.equals(conversationId)
.toArray();
// Convert to Message format expected by indexer
return storedMessages.map(m => ({
role: m.role,
content: m.content,
images: m.images,
toolCalls: m.toolCalls,
hidden: false
}));
}
/**
* Simple delay helper
*/
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Auto-run migration on module load (deferred)
* This ensures migration runs in the background after the app has loaded
*/
export function scheduleMigration(delayMs: number = 3000): void {
if (typeof window === 'undefined') return; // SSR guard
setTimeout(() => {
runChatIndexMigration({
batchSize: 2,
batchDelay: 1000, // 1 second between batches
minMessages: 2,
embeddingModel: settingsState.embeddingModel,
onProgress: (progress) => {
// Only log significant events
if (progress.indexed > 0 && progress.indexed % 5 === 0) {
console.log(`[ChatIndexMigration] Progress: ${progress.indexed}/${progress.total} indexed`);
}
}
}).catch(error => {
console.error('[ChatIndexMigration] Migration failed:', error);
});
}, delayMs);
}

View File

@@ -0,0 +1,362 @@
/**
* Chat Indexer Service
* Indexes conversation messages for RAG search across project chats
*/
import { db } from '$lib/storage/db.js';
import type { StoredChatChunk } from '$lib/storage/db.js';
import type { Message } from '$lib/types/chat.js';
import { generateId } from '$lib/storage/db.js';
import {
generateEmbedding,
findSimilar,
DEFAULT_EMBEDDING_MODEL
} from '$lib/memory/embeddings.js';
// ============================================================================
// Types
// ============================================================================
export interface IndexingOptions {
/** Embedding model to use (e.g., 'nomic-embed-text') */
embeddingModel?: string;
/** Base URL for Ollama API */
baseUrl?: string;
/** Only index assistant messages (recommended) */
assistantOnly?: boolean;
/** Minimum content length to index */
minContentLength?: number;
}
export interface ChatSearchResult {
conversationId: string;
conversationTitle: string;
messageId: string;
content: string;
similarity: number;
}
// ============================================================================
// Indexing Functions
// ============================================================================
/**
* Index messages from a conversation for RAG search
* Generates embeddings for each message and stores them for similarity search
* @param projectId - Project ID or null for global conversations
*/
export async function indexConversationMessages(
conversationId: string,
projectId: string | null,
messages: Message[],
options: IndexingOptions = {}
): Promise<number> {
const {
embeddingModel = DEFAULT_EMBEDDING_MODEL,
assistantOnly = false, // Index both user and assistant for better context
minContentLength = 20
} = options;
// Filter messages to index
const messagesToIndex = messages.filter((m) => {
if (assistantOnly && m.role !== 'assistant') return false;
if (m.role !== 'user' && m.role !== 'assistant') return false;
if (!m.content || m.content.length < minContentLength) return false;
if (m.hidden) return false;
return true;
});
if (messagesToIndex.length === 0) {
return 0;
}
// Check which messages are already indexed by checking if first 500 chars exist
const existingChunks = await db.chatChunks
.where('conversationId')
.equals(conversationId)
.toArray();
// Use first 500 chars as signature to detect already-indexed messages
const existingSignatures = new Set(existingChunks.map((c) => c.content.slice(0, 500)));
// Filter out already indexed messages
const newMessages = messagesToIndex.filter(
(m) => !existingSignatures.has(m.content.slice(0, 500))
);
if (newMessages.length === 0) {
return 0;
}
console.log(`[ChatIndexer] Indexing ${newMessages.length} new messages for conversation ${conversationId}`);
// Generate embeddings and create chunks
// For long messages, split into multiple chunks
const CHUNK_SIZE = 1500;
const CHUNK_OVERLAP = 200;
const chunks: StoredChatChunk[] = [];
for (let i = 0; i < newMessages.length; i++) {
const m = newMessages[i];
const content = m.content;
// Split long messages into chunks
const messageChunks: string[] = [];
if (content.length <= CHUNK_SIZE) {
messageChunks.push(content);
} else {
// Chunk with overlap for better context
let start = 0;
while (start < content.length) {
const end = Math.min(start + CHUNK_SIZE, content.length);
messageChunks.push(content.slice(start, end));
start = end - CHUNK_OVERLAP;
if (start >= content.length - CHUNK_OVERLAP) break;
}
}
// Create chunk for each piece
for (let j = 0; j < messageChunks.length; j++) {
const chunkContent = messageChunks[j];
try {
const embedding = await generateEmbedding(chunkContent, embeddingModel);
chunks.push({
id: generateId(),
conversationId,
projectId,
messageId: `${conversationId}-${Date.now()}-${i}-${j}`,
role: m.role as 'user' | 'assistant',
content: chunkContent,
embedding,
createdAt: Date.now()
});
} catch (error) {
console.error(`[ChatIndexer] Failed to generate embedding for chunk:`, error);
// Continue with other chunks
}
}
}
if (chunks.length > 0) {
await db.chatChunks.bulkAdd(chunks);
console.log(`[ChatIndexer] Successfully indexed ${chunks.length} messages`);
}
return chunks.length;
}
/**
* Force re-index a conversation (clears existing and re-indexes)
*/
export async function forceReindexConversation(
conversationId: string,
projectId: string,
messages: Message[],
options: IndexingOptions = {}
): Promise<number> {
console.log(`[ChatIndexer] Force re-indexing conversation: ${conversationId}`);
// Clear existing chunks
const deleted = await db.chatChunks.where('conversationId').equals(conversationId).delete();
console.log(`[ChatIndexer] Cleared ${deleted} existing chunks`);
// Re-index (this will now create chunked messages)
return indexConversationMessages(conversationId, projectId, messages, options);
}
/**
* Re-index a conversation when it moves to/from a project
*/
export async function reindexConversationForProject(
conversationId: string,
newProjectId: string | null
): Promise<void> {
// Remove existing chunks for this conversation
await db.chatChunks.where('conversationId').equals(conversationId).delete();
// If moving to a project, chunks will be re-created when needed
// For now, this is a placeholder - actual re-indexing would happen
// when the conversation is opened or when summaries are generated
}
/**
* Remove all indexed chunks for a conversation
*/
export async function removeConversationFromIndex(conversationId: string): Promise<void> {
await db.chatChunks.where('conversationId').equals(conversationId).delete();
}
/**
* Remove all indexed chunks for a project
*/
export async function removeProjectFromIndex(projectId: string): Promise<void> {
await db.chatChunks.where('projectId').equals(projectId).delete();
}
// ============================================================================
// Search Functions
// ============================================================================
export interface SearchChatOptions {
/** Project ID to search within, null for global search */
projectId?: string | null;
/** Conversation ID to exclude from results */
excludeConversationId?: string;
/** Maximum number of results */
topK?: number;
/** Minimum similarity threshold */
threshold?: number;
/** Embedding model to use */
embeddingModel?: string;
}
/**
* Search indexed chat history using embedding similarity
* Can search within a project, globally, or both
*/
export async function searchChatHistory(
query: string,
options: SearchChatOptions = {}
): Promise<ChatSearchResult[]> {
const {
projectId,
excludeConversationId,
topK = 10,
threshold = 0.2,
embeddingModel = DEFAULT_EMBEDDING_MODEL
} = options;
// Get chunks based on scope
let chunks: StoredChatChunk[];
if (projectId !== undefined) {
// Project-scoped search (projectId can be string or null)
if (projectId === null) {
// Search only global (non-project) conversations
chunks = await db.chatChunks.filter((c) => c.projectId === null).toArray();
} else {
// Search within specific project
chunks = await db.chatChunks.where('projectId').equals(projectId).toArray();
}
} else {
// Global search - all chunks
chunks = await db.chatChunks.toArray();
}
// Filter out excluded conversation and chunks without embeddings
const relevantChunks = chunks.filter((c) => {
if (excludeConversationId && c.conversationId === excludeConversationId) return false;
if (!c.embedding || c.embedding.length === 0) return false;
return true;
});
if (relevantChunks.length === 0) {
return [];
}
try {
// Generate embedding for query
const queryEmbedding = await generateEmbedding(query, embeddingModel);
// Validate embedding was generated successfully
if (!queryEmbedding || !Array.isArray(queryEmbedding) || queryEmbedding.length === 0) {
console.warn('[ChatIndexer] Failed to generate query embedding - is the embedding model available?');
return [];
}
// Find similar chunks
const similar = findSimilar(queryEmbedding, relevantChunks, topK, threshold);
if (similar.length === 0) {
return [];
}
// Get conversation titles for results
const conversationIds = [...new Set(similar.map((s) => s.conversationId))];
const conversations = await db.conversations.bulkGet(conversationIds);
const titleMap = new Map(
conversations.filter(Boolean).map((c) => [c!.id, c!.title])
);
// Format results
return similar.map((chunk) => ({
conversationId: chunk.conversationId,
conversationTitle: titleMap.get(chunk.conversationId) || 'Unknown',
messageId: chunk.messageId,
content: chunk.content,
similarity: chunk.similarity
}));
} catch (error) {
console.error('[ChatIndexer] Search failed:', error);
return [];
}
}
/**
* Search chat history within a specific project (legacy API)
*/
export async function searchProjectChatHistory(
projectId: string,
query: string,
excludeConversationId?: string,
topK: number = 10,
threshold: number = 0.2
): Promise<ChatSearchResult[]> {
return searchChatHistory(query, {
projectId,
excludeConversationId,
topK,
threshold
});
}
/**
* Search all indexed chat history globally
*/
export async function searchAllChatHistory(
query: string,
excludeConversationId?: string,
topK: number = 20,
threshold: number = 0.2
): Promise<ChatSearchResult[]> {
return searchChatHistory(query, {
excludeConversationId,
topK,
threshold
});
}
// ============================================================================
// Statistics
// ============================================================================
/**
* Get indexing statistics for a project
*/
export async function getProjectIndexStats(projectId: string): Promise<{
totalChunks: number;
conversationCount: number;
}> {
const chunks = await db.chatChunks
.where('projectId')
.equals(projectId)
.toArray();
const conversationIds = new Set(chunks.map((c) => c.conversationId));
return {
totalChunks: chunks.length,
conversationCount: conversationIds.size
};
}
/**
* Check if a conversation is indexed
*/
export async function isConversationIndexed(conversationId: string): Promise<boolean> {
const count = await db.chatChunks
.where('conversationId')
.equals(conversationId)
.count();
return count > 0;
}

View File

@@ -0,0 +1,205 @@
/**
* Conversation Summary Service
* Generates and manages conversation summaries for cross-chat context
*/
import { db } from '$lib/storage/db.js';
import { updateConversationSummary } from '$lib/storage/conversations.js';
import type { Message } from '$lib/types/chat.js';
import { indexConversationMessages } from './chat-indexer.js';
// ============================================================================
// Types
// ============================================================================
export interface SummaryGenerationOptions {
/** Model to use for summary generation */
model: string;
/** Base URL for Ollama API */
baseUrl?: string;
/** Maximum messages to include in summary context */
maxMessages?: number;
}
// ============================================================================
// Summary Generation
// ============================================================================
/**
* Generate a summary for a conversation using the LLM
* @param conversationId - The conversation to summarize
* @param messages - The messages to summarize
* @param options - Generation options
* @returns The generated summary text
*/
export async function generateConversationSummary(
conversationId: string,
messages: Message[],
options: SummaryGenerationOptions
): Promise<string> {
const { model, baseUrl = 'http://localhost:11434', maxMessages = 20 } = options;
// Filter to user and assistant messages only
const relevantMessages = messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.slice(-maxMessages); // Take last N messages
if (relevantMessages.length === 0) {
return '';
}
// Format messages for the prompt
const conversationText = relevantMessages
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 500)}`)
.join('\n\n');
const prompt = `Summarize this conversation in 2-3 sentences. Focus on the main topics discussed, any decisions made, and key outcomes. Be concise.
Conversation:
${conversationText}
Summary:`;
try {
const response = await fetch(`${baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt,
stream: false,
options: {
temperature: 0.3,
num_predict: 150
}
})
});
if (!response.ok) {
console.error('[ConversationSummary] Failed to generate summary:', response.statusText);
return '';
}
const data = await response.json();
return data.response?.trim() || '';
} catch (error) {
console.error('[ConversationSummary] Error generating summary:', error);
return '';
}
}
/**
* Generate and save a summary for a conversation
*/
export async function generateAndSaveSummary(
conversationId: string,
messages: Message[],
options: SummaryGenerationOptions
): Promise<boolean> {
const summary = await generateConversationSummary(conversationId, messages, options);
if (!summary) {
return false;
}
const result = await updateConversationSummary(conversationId, summary);
return result.success;
}
/**
* Check if a conversation needs its summary updated
* @param conversationId - The conversation to check
* @param currentMessageCount - Current number of messages
* @param threshold - Number of new messages before updating (default: 10)
*/
export async function needsSummaryUpdate(
conversationId: string,
currentMessageCount: number,
threshold: number = 10
): Promise<boolean> {
const conversation = await db.conversations.get(conversationId);
if (!conversation) {
return false;
}
// No summary yet - needs one if there are enough messages
if (!conversation.summary) {
return currentMessageCount >= 4; // At least 2 exchanges
}
// Check if enough new messages since last summary
// This is a simple heuristic - could be improved with actual message tracking
const lastSummaryTime = conversation.summaryUpdatedAt || conversation.createdAt;
const timeSinceLastSummary = Date.now() - lastSummaryTime;
// Update if more than 30 minutes old and conversation has grown
return timeSinceLastSummary > 30 * 60 * 1000 && currentMessageCount >= 6;
}
/**
* Get the summary prompt for manual triggering
*/
export function getSummaryPrompt(messages: Message[], maxMessages: number = 20): string {
const relevantMessages = messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.slice(-maxMessages);
const conversationText = relevantMessages
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 500)}`)
.join('\n\n');
return `Summarize this conversation in 2-3 sentences. Focus on the main topics discussed, any decisions made, and key outcomes. Be concise.
Conversation:
${conversationText}
Summary:`;
}
/**
* Trigger chat indexing and optional summary when user leaves a conversation
* Runs in background to not block navigation
* Indexes ALL conversations for global RAG search
* Only generates summaries for project conversations
*/
export async function updateSummaryOnLeave(
conversationId: string,
messages: Message[],
model: string,
baseUrl?: string
): Promise<void> {
// Get conversation to check project membership
const conversation = await db.conversations.get(conversationId);
if (!conversation) {
return;
}
const projectId = conversation.projectId || null;
// Run indexing and summary generation in background
setTimeout(async () => {
// Always index messages for RAG (all conversations, for global search)
try {
const indexed = await indexConversationMessages(conversationId, projectId, messages);
if (indexed > 0) {
console.log(`[ChatIndexer] Indexed ${indexed} chunks for conversation`);
}
} catch (error) {
console.error('[ChatIndexer] Indexing failed:', error);
}
// Generate summary only for project conversations (4+ messages and enough time passed)
if (projectId) {
const needsUpdate = await needsSummaryUpdate(conversationId, messages.length);
if (needsUpdate) {
try {
await generateAndSaveSummary(conversationId, messages, { model, baseUrl });
console.log('[Summary] Summary completed');
} catch (error) {
console.error('[Summary] Summary generation failed:', error);
}
}
}
}, 100);
}

View File

@@ -0,0 +1,266 @@
/**
* Project Context Service
* Builds full project context for chat messages including:
* - Project instructions
* - Conversation summaries from other project chats
* - RAG search across project chat history
* - Project reference links
*/
import { db, type StoredDocument } from '$lib/storage/db.js';
import {
getProjectConversationSummaries,
getConversationsForProject
} from '$lib/storage/conversations.js';
import type { ProjectLink } from '$lib/storage/projects.js';
import { getProjectLinks } from '$lib/storage/projects.js';
import { listDocuments, getDocumentChunks } from '$lib/memory/vector-store.js';
import { searchChatHistory, searchProjectChatHistory } from './chat-indexer.js';
// ============================================================================
// Types
// ============================================================================
export interface ConversationSummary {
id: string;
title: string;
summary: string;
updatedAt: Date;
}
/** Basic info about a project conversation */
export interface ProjectConversation {
id: string;
title: string;
messageCount: number;
updatedAt: Date;
hasSummary: boolean;
summary?: string;
}
export interface ChatHistoryResult {
conversationId: string;
conversationTitle: string;
content: string;
similarity: number;
}
/** Document info for context (simplified from StoredDocument) */
export interface ProjectDocument {
id: string;
name: string;
chunkCount: number;
embeddingStatus: 'pending' | 'processing' | 'ready' | 'failed' | undefined;
/** Preview of the document content (first chunk, truncated) */
preview?: string;
}
export interface ProjectContext {
/** Project instructions to inject into system prompt */
instructions: string | null;
/** All other conversations in the project (with summary status) */
otherConversations: ProjectConversation[];
/** Relevant snippets from chat history RAG search */
relevantChatHistory: ChatHistoryResult[];
/** Reference links for the project */
links: ProjectLink[];
/** Documents in the project's knowledge base */
documents: ProjectDocument[];
}
// ============================================================================
// Main Context Builder
// ============================================================================
/**
* Build full project context for a chat message
* @param projectId - The project ID
* @param currentConversationId - The current conversation (excluded from summaries/RAG)
* @param userQuery - The user's message (used for RAG search)
* @returns ProjectContext with all relevant context
*/
export async function buildProjectContext(
projectId: string,
currentConversationId: string,
userQuery: string
): Promise<ProjectContext> {
// Fetch project data in parallel
const [project, conversationsResult, summariesResult, linksResult, chatHistory, allDocuments] =
await Promise.all([
db.projects.get(projectId),
getConversationsForProject(projectId),
getProjectConversationSummaries(projectId, currentConversationId),
getProjectLinks(projectId),
searchProjectChatHistory(projectId, userQuery, currentConversationId, 3),
listDocuments()
]);
const allConversations = conversationsResult.success ? conversationsResult.data : [];
const summaries = summariesResult.success ? summariesResult.data : [];
const links = linksResult.success ? linksResult.data : [];
// Create a map of summaries by conversation ID for quick lookup
const summaryMap = new Map(summaries.map((s) => [s.id, s.summary]));
// Build list of other conversations (excluding current)
const otherConversations: ProjectConversation[] = allConversations
.filter((c) => c.id !== currentConversationId)
.map((c) => ({
id: c.id,
title: c.title,
messageCount: c.messageCount,
updatedAt: c.updatedAt,
hasSummary: summaryMap.has(c.id),
summary: summaryMap.get(c.id)
}));
// Filter documents for this project that are ready
const readyDocs = allDocuments.filter(
(d) => d.projectId === projectId && d.embeddingStatus === 'ready'
);
// Fetch previews for each document (first chunk, truncated)
const projectDocuments: ProjectDocument[] = await Promise.all(
readyDocs.map(async (d) => {
let preview: string | undefined;
try {
const chunks = await getDocumentChunks(d.id);
if (chunks.length > 0) {
// Get first chunk, truncate to ~500 chars
const firstChunk = chunks[0].content;
preview =
firstChunk.length > 500 ? firstChunk.slice(0, 500) + '...' : firstChunk;
}
} catch {
// Ignore errors fetching chunks
}
return {
id: d.id,
name: d.name,
chunkCount: d.chunkCount,
embeddingStatus: d.embeddingStatus,
preview
};
})
);
return {
instructions: project?.instructions || null,
otherConversations,
relevantChatHistory: chatHistory,
links,
documents: projectDocuments
};
}
// ============================================================================
// Chat History RAG Search
// ============================================================================
/**
* Search across project chat history using embeddings
* Returns relevant snippets from other conversations in the project
*/
export async function searchProjectChatHistoryLocal(
projectId: string,
query: string,
excludeConversationId?: string,
topK: number = 10,
threshold: number = 0.2
): Promise<ChatHistoryResult[]> {
const results = await searchChatHistory(query, {
projectId,
excludeConversationId,
topK,
threshold
});
return results.map((r) => ({
conversationId: r.conversationId,
conversationTitle: r.conversationTitle,
content: r.content,
similarity: r.similarity
}));
}
// ============================================================================
// Context Formatting
// ============================================================================
/**
* Format project context for injection into system prompt
*/
export function formatProjectContextForPrompt(context: ProjectContext): string {
const parts: string[] = [];
// Project instructions
if (context.instructions && context.instructions.trim()) {
parts.push(`## Project Instructions\n${context.instructions}`);
}
// Project knowledge base documents with previews
if (context.documents.length > 0) {
const docsText = context.documents
.map((d) => {
let entry = `### ${d.name}\n`;
if (d.preview) {
entry += `${d.preview}\n`;
} else {
entry += `(${d.chunkCount} chunks available)\n`;
}
return entry;
})
.join('\n');
parts.push(
`## Project Knowledge Base\nThe following documents are available. Use this content to answer questions about the project:\n\n${docsText}`
);
}
// Other conversations in this project
if (context.otherConversations.length > 0) {
const conversationsText = context.otherConversations
.slice(0, 10) // Limit to 10 most recent
.map((c) => {
if (c.hasSummary && c.summary) {
return `- **${c.title}**: ${c.summary}`;
} else {
return `- **${c.title}** (${c.messageCount} messages, no summary yet)`;
}
})
.join('\n');
parts.push(`## Other Chats in This Project\n${conversationsText}`);
}
// Relevant chat history (RAG results)
if (context.relevantChatHistory.length > 0) {
const historyText = context.relevantChatHistory
.map((h) => `From "${h.conversationTitle}":\n${h.content}`)
.join('\n\n---\n\n');
parts.push(`## Relevant Context from Past Conversations\n${historyText}`);
}
// Reference links
if (context.links.length > 0) {
const linksText = context.links
.slice(0, 5) // Limit to 5 links
.map((l) => `- [${l.title}](${l.url})${l.description ? `: ${l.description}` : ''}`)
.join('\n');
parts.push(`## Project Reference Links\n${linksText}`);
}
return parts.join('\n\n');
}
/**
* Check if project context has any content worth injecting
*/
export function hasProjectContext(context: ProjectContext): boolean {
return (
(context.instructions && context.instructions.trim().length > 0) ||
context.documents.length > 0 ||
context.otherConversations.length > 0 ||
context.relevantChatHistory.length > 0 ||
context.links.length > 0
);
}

View File

@@ -21,7 +21,10 @@ function toDomainConversation(stored: StoredConversation): Conversation {
isPinned: stored.isPinned,
isArchived: stored.isArchived,
messageCount: stored.messageCount,
systemPromptId: stored.systemPromptId ?? null
systemPromptId: stored.systemPromptId ?? null,
projectId: stored.projectId ?? null,
summary: stored.summary ?? null,
summaryUpdatedAt: stored.summaryUpdatedAt ? new Date(stored.summaryUpdatedAt) : null
};
}
@@ -126,7 +129,7 @@ export async function getConversationFull(id: string): Promise<StorageResult<Con
* Create a new conversation
*/
export async function createConversation(
data: Omit<Conversation, 'id' | 'createdAt' | 'updatedAt' | 'messageCount'>
data: Omit<Conversation, 'id' | 'createdAt' | 'updatedAt' | 'messageCount' | 'summary' | 'summaryUpdatedAt'>
): Promise<StorageResult<Conversation>> {
return withErrorHandling(async () => {
const id = generateId();
@@ -142,7 +145,8 @@ export async function createConversation(
isArchived: data.isArchived ?? false,
messageCount: 0,
syncVersion: 1,
systemPromptId: data.systemPromptId ?? null
systemPromptId: data.systemPromptId ?? null,
projectId: data.projectId ?? null
};
await db.conversations.add(stored);
@@ -311,3 +315,128 @@ export async function searchConversations(query: string): Promise<StorageResult<
return matching.map(toDomainConversation);
});
}
// ============================================================================
// Project-related operations
// ============================================================================
/**
* Get all conversations for a specific project
*/
export async function getConversationsForProject(
projectId: string
): Promise<StorageResult<Conversation[]>> {
return withErrorHandling(async () => {
const conversations = await db.conversations
.where('projectId')
.equals(projectId)
.toArray();
const sorted = conversations
.filter((c) => !c.isArchived)
.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return b.updatedAt - a.updatedAt;
});
return sorted.map(toDomainConversation);
});
}
/**
* Get all conversations without a project (ungrouped)
*/
export async function getConversationsWithoutProject(): Promise<StorageResult<Conversation[]>> {
return withErrorHandling(async () => {
const all = await db.conversations.toArray();
const ungrouped = all
.filter((c) => !c.isArchived && (!c.projectId || c.projectId === null))
.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return b.updatedAt - a.updatedAt;
});
return ungrouped.map(toDomainConversation);
});
}
/**
* Move a conversation to a project (or remove from project if null)
*/
export async function moveConversationToProject(
conversationId: string,
projectId: string | null
): Promise<StorageResult<Conversation>> {
return withErrorHandling(async () => {
const existing = await db.conversations.get(conversationId);
if (!existing) {
throw new Error(`Conversation not found: ${conversationId}`);
}
const updated: StoredConversation = {
...existing,
projectId: projectId,
updatedAt: Date.now(),
syncVersion: (existing.syncVersion ?? 0) + 1
};
await db.conversations.put(updated);
await markForSync('conversation', conversationId, 'update');
return toDomainConversation(updated);
});
}
/**
* Update conversation summary (for cross-chat context)
*/
export async function updateConversationSummary(
conversationId: string,
summary: string
): Promise<StorageResult<Conversation>> {
return withErrorHandling(async () => {
const existing = await db.conversations.get(conversationId);
if (!existing) {
throw new Error(`Conversation not found: ${conversationId}`);
}
const updated: StoredConversation = {
...existing,
summary,
summaryUpdatedAt: Date.now(),
updatedAt: Date.now(),
syncVersion: (existing.syncVersion ?? 0) + 1
};
await db.conversations.put(updated);
return toDomainConversation(updated);
});
}
/**
* Get conversation summaries for all conversations in a project (excluding current)
*/
export async function getProjectConversationSummaries(
projectId: string,
excludeConversationId?: string
): Promise<StorageResult<Array<{ id: string; title: string; summary: string; updatedAt: Date }>>> {
return withErrorHandling(async () => {
const conversations = await db.conversations
.where('projectId')
.equals(projectId)
.toArray();
return conversations
.filter((c) => !c.isArchived && c.summary && c.id !== excludeConversationId)
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((c) => ({
id: c.id,
title: c.title,
summary: c.summary!,
updatedAt: new Date(c.summaryUpdatedAt ?? c.updatedAt)
}));
});
}

View File

@@ -21,6 +21,12 @@ export interface StoredConversation {
syncVersion?: number;
/** Optional system prompt ID for this conversation */
systemPromptId?: string | null;
/** Optional project ID this conversation belongs to */
projectId?: string | null;
/** Auto-generated conversation summary for cross-chat context */
summary?: string | null;
/** Timestamp when summary was last updated */
summaryUpdatedAt?: number | null;
}
/**
@@ -38,6 +44,12 @@ export interface ConversationRecord {
syncVersion?: number;
/** Optional system prompt ID for this conversation */
systemPromptId?: string | null;
/** Optional project ID this conversation belongs to */
projectId?: string | null;
/** Auto-generated conversation summary for cross-chat context */
summary?: string | null;
/** Timestamp when summary was last updated */
summaryUpdatedAt?: Date | null;
}
/**
@@ -143,6 +155,10 @@ export interface StoredDocument {
updatedAt: number;
chunkCount: number;
embeddingModel: string;
/** Optional project ID - if set, document is project-scoped */
projectId?: string | null;
/** Embedding generation status: 'pending' | 'processing' | 'ready' | 'failed' */
embeddingStatus?: 'pending' | 'processing' | 'ready' | 'failed';
}
/**
@@ -201,6 +217,55 @@ export interface StoredModelPromptMapping {
updatedAt: number;
}
// ============================================================================
// Project-related interfaces (v6)
// ============================================================================
/**
* Project for organizing conversations with shared context
*/
export interface StoredProject {
id: string;
name: string;
description: string;
/** Instructions injected into system prompt for all project chats */
instructions: string;
/** Hex color for UI display */
color: string;
/** Whether folder is collapsed in sidebar */
isCollapsed: boolean;
createdAt: number;
updatedAt: number;
}
/**
* Reference link attached to a project
*/
export interface StoredProjectLink {
id: string;
projectId: string;
url: string;
title: string;
description: string;
createdAt: number;
}
/**
* Chat message chunk with embedding for cross-chat RAG
* Enables searching across conversation history (project-scoped or global)
*/
export interface StoredChatChunk {
id: string;
conversationId: string;
/** Project ID for project-scoped queries, null for global conversations */
projectId: string | null;
messageId: string;
role: 'user' | 'assistant';
content: string;
embedding: number[];
createdAt: number;
}
/**
* Ollama WebUI database class
* Manages all local storage tables
@@ -215,6 +280,10 @@ class OllamaDatabase extends Dexie {
prompts!: Table<StoredPrompt>;
modelSystemPrompts!: Table<StoredModelSystemPrompt>;
modelPromptMappings!: Table<StoredModelPromptMapping>;
// Project-related tables (v6)
projects!: Table<StoredProject>;
projectLinks!: Table<StoredProjectLink>;
chatChunks!: Table<StoredChatChunk>;
constructor() {
super('vessel');
@@ -283,6 +352,28 @@ class OllamaDatabase extends Dexie {
// User-configured model-to-prompt mappings
modelPromptMappings: 'id, modelName, promptId'
});
// Version 6: Projects with cross-chat context sharing
// Adds: projects, project links, chat chunks for RAG, projectId on conversations/documents
this.version(6).stores({
// Add projectId index for filtering conversations by project
conversations: 'id, updatedAt, isPinned, isArchived, systemPromptId, projectId',
messages: 'id, conversationId, parentId, createdAt',
attachments: 'id, messageId',
syncQueue: 'id, entityType, createdAt',
// Add projectId index for project-scoped document RAG
documents: 'id, name, createdAt, updatedAt, projectId',
chunks: 'id, documentId',
prompts: 'id, name, isDefault, updatedAt',
modelSystemPrompts: 'modelName',
modelPromptMappings: 'id, modelName, promptId',
// Projects for organizing conversations
projects: 'id, name, createdAt, updatedAt',
// Reference links attached to projects
projectLinks: 'id, projectId, createdAt',
// Chat message chunks for cross-conversation RAG within projects
chatChunks: 'id, conversationId, projectId, createdAt'
});
}
}

View File

@@ -0,0 +1,310 @@
/**
* Project CRUD operations for IndexedDB storage
*/
import { db, withErrorHandling, generateId } from './db.js';
import type { StoredProject, StoredProjectLink, StorageResult } from './db.js';
// ============================================================================
// Types
// ============================================================================
export interface Project {
id: string;
name: string;
description: string;
instructions: string;
color: string;
isCollapsed: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface ProjectLink {
id: string;
projectId: string;
url: string;
title: string;
description: string;
createdAt: Date;
}
export interface CreateProjectData {
name: string;
description?: string;
instructions?: string;
color?: string;
}
export interface UpdateProjectData {
name?: string;
description?: string;
instructions?: string;
color?: string;
isCollapsed?: boolean;
}
export interface CreateProjectLinkData {
projectId: string;
url: string;
title: string;
description?: string;
}
// ============================================================================
// Converters
// ============================================================================
function toDomainProject(stored: StoredProject): Project {
return {
id: stored.id,
name: stored.name,
description: stored.description,
instructions: stored.instructions,
color: stored.color,
isCollapsed: stored.isCollapsed,
createdAt: new Date(stored.createdAt),
updatedAt: new Date(stored.updatedAt)
};
}
function toDomainProjectLink(stored: StoredProjectLink): ProjectLink {
return {
id: stored.id,
projectId: stored.projectId,
url: stored.url,
title: stored.title,
description: stored.description,
createdAt: new Date(stored.createdAt)
};
}
// Default project colors (tailwind-inspired)
const PROJECT_COLORS = [
'#8b5cf6', // violet-500
'#06b6d4', // cyan-500
'#10b981', // emerald-500
'#f59e0b', // amber-500
'#ef4444', // red-500
'#ec4899', // pink-500
'#3b82f6', // blue-500
'#84cc16' // lime-500
];
function getRandomColor(): string {
return PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)];
}
// ============================================================================
// Project CRUD
// ============================================================================
/**
* Get all projects, sorted by name
*/
export async function getAllProjects(): Promise<StorageResult<Project[]>> {
return withErrorHandling(async () => {
const all = await db.projects.toArray();
const sorted = all.sort((a, b) => a.name.localeCompare(b.name));
return sorted.map(toDomainProject);
});
}
/**
* Get a single project by ID
*/
export async function getProject(id: string): Promise<StorageResult<Project | null>> {
return withErrorHandling(async () => {
const stored = await db.projects.get(id);
return stored ? toDomainProject(stored) : null;
});
}
/**
* Create a new project
*/
export async function createProject(data: CreateProjectData): Promise<StorageResult<Project>> {
return withErrorHandling(async () => {
const now = Date.now();
const stored: StoredProject = {
id: generateId(),
name: data.name,
description: data.description || '',
instructions: data.instructions || '',
color: data.color || getRandomColor(),
isCollapsed: false,
createdAt: now,
updatedAt: now
};
await db.projects.add(stored);
return toDomainProject(stored);
});
}
/**
* Update an existing project
*/
export async function updateProject(
id: string,
updates: UpdateProjectData
): Promise<StorageResult<Project>> {
return withErrorHandling(async () => {
const existing = await db.projects.get(id);
if (!existing) {
throw new Error(`Project not found: ${id}`);
}
const updated: StoredProject = {
...existing,
...updates,
updatedAt: Date.now()
};
await db.projects.put(updated);
return toDomainProject(updated);
});
}
/**
* Delete a project and all associated data
* - Unlinks all conversations (sets projectId to null)
* - Deletes all project links
* - Deletes all project documents
* - Deletes all chat chunks for the project
*/
export async function deleteProject(id: string): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.transaction('rw', [db.projects, db.projectLinks, db.conversations, db.documents, db.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) {
await db.conversations.update(conv.id, { projectId: null });
}
// Delete all project links
await db.projectLinks.where('projectId').equals(id).delete();
// Delete all project documents (and their chunks)
const documents = await db.documents.where('projectId').equals(id).toArray();
for (const doc of documents) {
await db.chunks.where('documentId').equals(doc.id).delete();
}
await db.documents.where('projectId').equals(id).delete();
// Delete all chat chunks for this project
await db.chatChunks.where('projectId').equals(id).delete();
// Delete the project itself
await db.projects.delete(id);
});
});
}
/**
* Toggle project collapse state
*/
export async function toggleProjectCollapse(id: string): Promise<StorageResult<boolean>> {
return withErrorHandling(async () => {
const existing = await db.projects.get(id);
if (!existing) {
throw new Error(`Project not found: ${id}`);
}
const newState = !existing.isCollapsed;
await db.projects.update(id, { isCollapsed: newState });
return newState;
});
}
// ============================================================================
// Project Links CRUD
// ============================================================================
/**
* Get all links for a project
*/
export async function getProjectLinks(projectId: string): Promise<StorageResult<ProjectLink[]>> {
return withErrorHandling(async () => {
const links = await db.projectLinks.where('projectId').equals(projectId).toArray();
return links.map(toDomainProjectLink);
});
}
/**
* Add a link to a project
*/
export async function addProjectLink(data: CreateProjectLinkData): Promise<StorageResult<ProjectLink>> {
return withErrorHandling(async () => {
const stored: StoredProjectLink = {
id: generateId(),
projectId: data.projectId,
url: data.url,
title: data.title,
description: data.description || '',
createdAt: Date.now()
};
await db.projectLinks.add(stored);
return toDomainProjectLink(stored);
});
}
/**
* Update a project link
*/
export async function updateProjectLink(
id: string,
updates: Partial<Pick<ProjectLink, 'url' | 'title' | 'description'>>
): Promise<StorageResult<ProjectLink>> {
return withErrorHandling(async () => {
const existing = await db.projectLinks.get(id);
if (!existing) {
throw new Error(`Project link not found: ${id}`);
}
const updated: StoredProjectLink = {
...existing,
...updates
};
await db.projectLinks.put(updated);
return toDomainProjectLink(updated);
});
}
/**
* Delete a project link
*/
export async function deleteProjectLink(id: string): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.projectLinks.delete(id);
});
}
// ============================================================================
// Project Statistics
// ============================================================================
/**
* Get statistics for a project
*/
export async function getProjectStats(projectId: string): Promise<StorageResult<{
conversationCount: number;
documentCount: number;
linkCount: number;
}>> {
return withErrorHandling(async () => {
const [conversations, documents, links] = await Promise.all([
db.conversations.where('projectId').equals(projectId).count(),
db.documents.where('projectId').equals(projectId).count(),
db.projectLinks.where('projectId').equals(projectId).count()
]);
return {
conversationCount: conversations,
documentCount: documents,
linkCount: links
};
});
}

View File

@@ -204,6 +204,66 @@ export class ConversationsState {
setSystemPrompt(id: string, systemPromptId: string | null): void {
this.update(id, { systemPromptId });
}
// ========================================================================
// Project-related methods
// ========================================================================
/**
* Get conversations for a specific project
* @param projectId The project ID
*/
forProject(projectId: string): Conversation[] {
return this.items
.filter((c) => !c.isArchived && c.projectId === projectId)
.sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
return b.updatedAt.getTime() - a.updatedAt.getTime();
});
}
/**
* Get conversations without a project
*/
withoutProject(): Conversation[] {
return this.items
.filter((c) => !c.isArchived && (!c.projectId || c.projectId === null))
.sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
return b.updatedAt.getTime() - a.updatedAt.getTime();
});
}
/**
* Move a conversation to a project (or remove from project if null)
* @param id The conversation ID
* @param projectId The project ID (or null to remove from project)
*/
moveToProject(id: string, projectId: string | null): void {
this.update(id, { projectId });
}
/**
* Update a conversation's summary
* @param id The conversation ID
* @param summary The summary text
*/
updateSummary(id: string, summary: string): void {
this.update(id, { summary, summaryUpdatedAt: new Date() });
}
/**
* Get all project IDs that have conversations
*/
getProjectIdsWithConversations(): string[] {
const projectIds = new Set<string>();
for (const c of this.items) {
if (!c.isArchived && c.projectId) {
projectIds.add(c.projectId);
}
}
return Array.from(projectIds);
}
}
/** Singleton conversations state instance */

View File

@@ -12,6 +12,7 @@ export { promptsState } from './prompts.svelte.js';
export { SettingsState, settingsState } from './settings.svelte.js';
export type { Prompt } from './prompts.svelte.js';
export { VersionState, versionState } from './version.svelte.js';
export { ProjectsState, projectsState } from './projects.svelte.js';
// Re-export types for convenience
export type { GroupedConversations } from './conversations.svelte.js';

View File

@@ -23,12 +23,10 @@ export const CAPABILITY_INFO: Record<string, { label: string; icon: string; colo
};
/**
* Middleware models that should NOT appear in the chat model selector
* These are special-purpose models for embeddings, function routing, etc.
* Embedding model patterns for semantic search/RAG
*/
const MIDDLEWARE_MODEL_PATTERNS = [
const EMBEDDING_MODEL_PATTERNS = [
'embeddinggemma',
'functiongemma',
'nomic-embed',
'mxbai-embed',
'all-minilm',
@@ -36,7 +34,16 @@ const MIDDLEWARE_MODEL_PATTERNS = [
'bge-', // BGE embedding models
'e5-', // E5 embedding models
'gte-', // GTE embedding models
'embed' // Generic embed pattern (catches most embedding models)
'embed' // Generic embed pattern
];
/**
* Middleware models that should NOT appear in the chat model selector
* These are special-purpose models for embeddings, function routing, etc.
*/
const MIDDLEWARE_MODEL_PATTERNS = [
...EMBEDDING_MODEL_PATTERNS,
'functiongemma' // Function routing model
];
/** Check if a model is a middleware/utility model (not for direct chat) */
@@ -45,6 +52,12 @@ function isMiddlewareModel(model: OllamaModel): boolean {
return MIDDLEWARE_MODEL_PATTERNS.some((pattern) => name.includes(pattern));
}
/** Check if a model is an embedding model */
function isEmbeddingModel(model: OllamaModel): boolean {
const name = model.name.toLowerCase();
return EMBEDDING_MODEL_PATTERNS.some((pattern) => name.includes(pattern));
}
/** Check if a model supports vision */
function isVisionModel(model: OllamaModel): boolean {
const name = model.name.toLowerCase();
@@ -139,6 +152,16 @@ export class ModelsState {
return this.available.filter(isVisionModel);
});
// Derived: Embedding models available for RAG/semantic search
embeddingModels = $derived.by(() => {
return this.available.filter(isEmbeddingModel);
});
// Derived: Check if any embedding model is available
hasEmbeddingModel = $derived.by(() => {
return this.embeddingModels.length > 0;
});
// Derived: Check if selected model supports vision
// Uses capabilities cache first (from Ollama API), falls back to pattern matching
selectedSupportsVision = $derived.by(() => {

View File

@@ -0,0 +1,150 @@
/**
* Projects state management using Svelte 5 runes
* Handles project list, selection, and CRUD operations
*/
import type { Project, ProjectLink } from '$lib/storage/projects.js';
import * as projectStorage from '$lib/storage/projects.js';
// Re-export types for convenience
export type { Project, ProjectLink };
/** Projects state class with reactive properties */
export class ProjectsState {
// Core state
projects = $state<Project[]>([]);
activeProjectId = $state<string | null>(null);
isLoading = $state(false);
hasLoaded = $state(false); // True after first successful load
error = $state<string | null>(null);
// Derived: Active project
activeProject = $derived.by(() => {
if (!this.activeProjectId) return null;
return this.projects.find((p) => p.id === this.activeProjectId) ?? null;
});
// Derived: Projects sorted by name
sortedProjects = $derived.by(() => {
return [...this.projects].sort((a, b) => a.name.localeCompare(b.name));
});
// Derived: Collapsed project IDs for quick lookup
collapsedIds = $derived.by(() => {
return new Set(this.projects.filter((p) => p.isCollapsed).map((p) => p.id));
});
/**
* Load all projects from storage
*/
async load(): Promise<void> {
this.isLoading = true;
this.error = null;
try {
const result = await projectStorage.getAllProjects();
if (result.success) {
this.projects = result.data;
this.hasLoaded = true;
} else {
this.error = result.error;
console.error('[ProjectsState] Failed to load projects:', result.error);
}
} finally {
this.isLoading = false;
}
}
/**
* Create a new project
*/
async add(data: projectStorage.CreateProjectData): Promise<Project | null> {
this.error = null;
const result = await projectStorage.createProject(data);
if (result.success) {
this.projects = [...this.projects, result.data];
return result.data;
} else {
this.error = result.error;
console.error('[ProjectsState] Failed to create project:', result.error);
return null;
}
}
/**
* Update an existing project
*/
async update(id: string, updates: projectStorage.UpdateProjectData): Promise<boolean> {
this.error = null;
const result = await projectStorage.updateProject(id, updates);
if (result.success) {
this.projects = this.projects.map((p) =>
p.id === id ? result.data : p
);
return true;
} else {
this.error = result.error;
console.error('[ProjectsState] Failed to update project:', result.error);
return false;
}
}
/**
* Delete a project
*/
async remove(id: string): Promise<boolean> {
this.error = null;
const result = await projectStorage.deleteProject(id);
if (result.success) {
this.projects = this.projects.filter((p) => p.id !== id);
// Clear active project if it was deleted
if (this.activeProjectId === id) {
this.activeProjectId = null;
}
return true;
} else {
this.error = result.error;
console.error('[ProjectsState] Failed to delete project:', result.error);
return false;
}
}
/**
* Toggle project collapse state
*/
async toggleCollapse(id: string): Promise<void> {
const result = await projectStorage.toggleProjectCollapse(id);
if (result.success) {
this.projects = this.projects.map((p) =>
p.id === id ? { ...p, isCollapsed: result.data } : p
);
}
}
/**
* Set the active project (for filtering)
*/
setActive(id: string | null): void {
this.activeProjectId = id;
}
/**
* Find a project by ID
*/
find(id: string): Project | undefined {
return this.projects.find((p) => p.id === id);
}
/**
* Check if a project is collapsed
*/
isCollapsed(id: string): boolean {
return this.collapsedIds.has(id);
}
}
/** Singleton projects state instance */
export const projectsState = new ProjectsState();

View File

@@ -14,6 +14,7 @@ import {
AUTO_COMPACT_RANGES
} from '$lib/types/settings';
import type { ModelDefaults } from './models.svelte';
import { DEFAULT_EMBEDDING_MODEL } from '$lib/memory/embeddings';
const STORAGE_KEY = 'vessel-settings';
@@ -38,6 +39,9 @@ export class SettingsState {
autoCompactThreshold = $state(DEFAULT_AUTO_COMPACT_SETTINGS.threshold);
autoCompactPreserveCount = $state(DEFAULT_AUTO_COMPACT_SETTINGS.preserveCount);
// Embedding model for semantic search
embeddingModel = $state(DEFAULT_EMBEDDING_MODEL);
// Derived: Current model parameters object
modelParameters = $derived.by((): ModelParameters => ({
temperature: this.temperature,
@@ -175,6 +179,14 @@ export class SettingsState {
this.saveToStorage();
}
/**
* Update embedding model for semantic search
*/
updateEmbeddingModel(model: string): void {
this.embeddingModel = model;
this.saveToStorage();
}
/**
* Load settings from localStorage
*/
@@ -196,6 +208,9 @@ export class SettingsState {
this.autoCompactEnabled = settings.autoCompact?.enabled ?? DEFAULT_AUTO_COMPACT_SETTINGS.enabled;
this.autoCompactThreshold = settings.autoCompact?.threshold ?? DEFAULT_AUTO_COMPACT_SETTINGS.threshold;
this.autoCompactPreserveCount = settings.autoCompact?.preserveCount ?? DEFAULT_AUTO_COMPACT_SETTINGS.preserveCount;
// Embedding model
this.embeddingModel = settings.embeddingModel ?? DEFAULT_EMBEDDING_MODEL;
} catch (error) {
console.warn('[Settings] Failed to load from localStorage:', error);
}
@@ -213,7 +228,8 @@ export class SettingsState {
enabled: this.autoCompactEnabled,
threshold: this.autoCompactThreshold,
preserveCount: this.autoCompactPreserveCount
}
},
embeddingModel: this.embeddingModel
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));

View File

@@ -370,3 +370,94 @@ export function updateToolCallState(
endTime: Date.now()
};
}
/**
* Result of parsing text-based tool calls from content
*/
export interface TextToolCallParseResult {
/** Any tool calls found in the content */
toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>;
/** Content with tool calls removed (for display) */
cleanContent: string;
}
/**
* Parse text-based tool calls from model output
*
* Models without native function calling may output tool calls as plain text
* in formats like:
* - tool_name[ARGS]{json}
* - <tool_call>{"name": "...", "arguments": {...}}</tool_call>
*
* This function detects and parses these formats.
*/
export function parseTextToolCalls(content: string): TextToolCallParseResult {
const toolCalls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
let cleanContent = content;
// Pattern 1: tool_name[ARGS]{json} or tool_name[ARGS]{"key": "value"}
const argsPattern = /(\w+)\[ARGS\]\s*(\{[\s\S]*?\})/g;
const argsMatches = [...content.matchAll(argsPattern)];
for (const match of argsMatches) {
const [fullMatch, toolName, argsJson] = match;
try {
const args = JSON.parse(argsJson);
toolCalls.push({ name: toolName, arguments: args });
cleanContent = cleanContent.replace(fullMatch, '').trim();
} catch {
// JSON parse failed, skip this match
console.warn(`Failed to parse tool call arguments: ${argsJson}`);
}
}
// Pattern 2: <tool_call>{"name": "tool_name", "arguments": {...}}</tool_call>
const xmlPattern = /<tool_call>\s*(\{[\s\S]*?\})\s*<\/tool_call>/g;
const xmlMatches = [...content.matchAll(xmlPattern)];
for (const match of xmlMatches) {
const [fullMatch, json] = match;
try {
const parsed = JSON.parse(json);
if (parsed.name && parsed.arguments) {
toolCalls.push({
name: parsed.name,
arguments: typeof parsed.arguments === 'string'
? JSON.parse(parsed.arguments)
: parsed.arguments
});
cleanContent = cleanContent.replace(fullMatch, '').trim();
}
} catch {
console.warn(`Failed to parse XML tool call: ${json}`);
}
}
// Pattern 3: {"tool_calls": [{"function": {"name": "...", "arguments": {...}}}]}
const jsonBlobPattern = /\{[\s\S]*?"tool_calls"\s*:\s*\[[\s\S]*?\]\s*\}/g;
const jsonMatches = [...content.matchAll(jsonBlobPattern)];
for (const match of jsonMatches) {
const [fullMatch] = match;
try {
const parsed = JSON.parse(fullMatch);
if (Array.isArray(parsed.tool_calls)) {
for (const tc of parsed.tool_calls) {
if (tc.function?.name) {
toolCalls.push({
name: tc.function.name,
arguments: typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments || {}
});
}
}
cleanContent = cleanContent.replace(fullMatch, '').trim();
}
} catch {
// Not valid JSON, skip
}
}
return { toolCalls, cleanContent };
}

View File

@@ -8,11 +8,13 @@ export {
toolRegistry,
executeCustomTool,
parseToolCall,
parseTextToolCalls,
runToolCall,
runToolCalls,
formatToolResultsForChat,
createToolCallState,
updateToolCallState
updateToolCallState,
type TextToolCallParseResult
} from './executor.js';
export {
PREFERRED_FUNCTION_MODEL,

View File

@@ -8,7 +8,7 @@ export interface ToolTemplate {
id: string;
name: string;
description: string;
category: 'api' | 'data' | 'utility' | 'integration';
category: 'api' | 'data' | 'utility' | 'integration' | 'agentic';
language: ToolImplementation;
code: string;
parameters: JSONSchema;
@@ -166,6 +166,184 @@ return {
}
},
{
id: 'js-design-brief',
name: 'Design Brief Generator',
description: 'Generate structured design briefs from project requirements',
category: 'utility',
language: 'javascript',
code: `// Generate a structured design brief from requirements
const projectType = args.project_type || 'website';
const style = args.style_preferences || 'modern, clean';
const features = args.key_features || '';
const audience = args.target_audience || 'general users';
const brand = args.brand_keywords || '';
const brief = {
project_type: projectType,
design_direction: {
style: style,
mood: style.includes('playful') ? 'energetic and fun' :
style.includes('corporate') ? 'professional and trustworthy' :
style.includes('minimal') ? 'clean and focused' :
'balanced and approachable',
inspiration_keywords: [
...style.split(',').map(s => s.trim()),
projectType,
...(brand ? brand.split(',').map(s => s.trim()) : [])
].filter(Boolean)
},
target_audience: audience,
key_sections: features ? features.split(',').map(f => f.trim()) : [
'Hero section with clear value proposition',
'Features/Benefits overview',
'Social proof or testimonials',
'Call to action'
],
ui_recommendations: {
typography: style.includes('modern') ? 'Sans-serif (Inter, Geist, or similar)' :
style.includes('elegant') ? 'Serif accents with sans-serif body' :
'Clean sans-serif for readability',
color_approach: style.includes('minimal') ? 'Monochromatic with single accent' :
style.includes('bold') ? 'High contrast with vibrant accents' :
'Balanced palette with primary and secondary colors',
spacing: 'Generous whitespace for visual breathing room',
imagery: style.includes('corporate') ? 'Professional photography or abstract graphics' :
style.includes('playful') ? 'Illustrations or playful iconography' :
'High-quality, contextual imagery'
},
accessibility_notes: [
'Ensure 4.5:1 contrast ratio for text',
'Include focus states for keyboard navigation',
'Use semantic HTML structure',
'Provide alt text for all images'
]
};
return brief;`,
parameters: {
type: 'object',
properties: {
project_type: {
type: 'string',
description: 'Type of project (landing page, dashboard, mobile app, e-commerce, portfolio, etc.)'
},
style_preferences: {
type: 'string',
description: 'Preferred style keywords (modern, minimal, playful, corporate, elegant, bold, etc.)'
},
key_features: {
type: 'string',
description: 'Comma-separated list of main features or sections needed'
},
target_audience: {
type: 'string',
description: 'Description of target users (developers, enterprise, consumers, etc.)'
},
brand_keywords: {
type: 'string',
description: 'Keywords that describe the brand personality'
}
},
required: ['project_type']
}
},
{
id: 'js-color-palette',
name: 'Color Palette Generator',
description: 'Generate harmonious color palettes from a base color',
category: 'utility',
language: 'javascript',
code: `// Generate color palette from base color
const hexToHsl = (hex) => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
};
const hslToHex = (h, s, l) => {
s /= 100; l /= 100;
const a = s * Math.min(l, 1 - l);
const f = n => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return '#' + f(0) + f(8) + f(4);
};
const baseColor = args.base_color || '#3b82f6';
const harmony = args.harmony || 'complementary';
const base = hexToHsl(baseColor);
const colors = { primary: baseColor };
switch (harmony) {
case 'complementary':
colors.secondary = hslToHex((base.h + 180) % 360, base.s, base.l);
colors.accent = hslToHex((base.h + 30) % 360, base.s, base.l);
break;
case 'analogous':
colors.secondary = hslToHex((base.h + 30) % 360, base.s, base.l);
colors.accent = hslToHex((base.h - 30 + 360) % 360, base.s, base.l);
break;
case 'triadic':
colors.secondary = hslToHex((base.h + 120) % 360, base.s, base.l);
colors.accent = hslToHex((base.h + 240) % 360, base.s, base.l);
break;
case 'split-complementary':
colors.secondary = hslToHex((base.h + 150) % 360, base.s, base.l);
colors.accent = hslToHex((base.h + 210) % 360, base.s, base.l);
break;
}
// Add neutrals
colors.background = hslToHex(base.h, 10, 98);
colors.surface = hslToHex(base.h, 10, 95);
colors.text = hslToHex(base.h, 10, 15);
colors.muted = hslToHex(base.h, 10, 45);
// Add primary shades
colors.primary_light = hslToHex(base.h, base.s, Math.min(base.l + 20, 95));
colors.primary_dark = hslToHex(base.h, base.s, Math.max(base.l - 20, 15));
return {
harmony,
palette: colors,
css_variables: Object.entries(colors).map(([k, v]) => \`--color-\${k.replace('_', '-')}: \${v};\`).join('\\n')
};`,
parameters: {
type: 'object',
properties: {
base_color: {
type: 'string',
description: 'Base color in hex format (e.g., #3b82f6)'
},
harmony: {
type: 'string',
description: 'Color harmony: complementary, analogous, triadic, split-complementary'
}
},
required: ['base_color']
}
},
// Python Templates
{
id: 'py-api-fetch',
@@ -336,6 +514,580 @@ print(json.dumps(result))`,
},
required: ['text', 'operation']
}
},
// Agentic Templates
{
id: 'js-task-manager',
name: 'Task Manager',
description:
'TASK TRACKING: Use when the user mentions tasks, todos, or things to do. Actions: add (create task), complete (mark done), list (show all), remove (delete). Persists across conversations. Use for any "add to my list", "what tasks", "mark as done" requests.',
category: 'agentic',
language: 'javascript',
code: `// Task Manager with localStorage persistence
const STORAGE_KEY = 'vessel_agent_tasks';
const loadTasks = () => {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch { return []; }
};
const saveTasks = (tasks) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
};
const action = args.action;
let tasks = loadTasks();
switch (action) {
case 'create': {
const task = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
title: args.title,
description: args.description || '',
priority: args.priority || 'medium',
status: 'pending',
created: new Date().toISOString(),
due: args.due || null,
tags: args.tags || []
};
tasks.push(task);
saveTasks(tasks);
return { success: true, task, message: 'Task created' };
}
case 'list': {
let filtered = tasks;
if (args.status) filtered = filtered.filter(t => t.status === args.status);
if (args.priority) filtered = filtered.filter(t => t.priority === args.priority);
if (args.tag) filtered = filtered.filter(t => t.tags?.includes(args.tag));
return {
tasks: filtered,
total: tasks.length,
pending: tasks.filter(t => t.status === 'pending').length,
completed: tasks.filter(t => t.status === 'completed').length
};
}
case 'update': {
const idx = tasks.findIndex(t => t.id === args.id);
if (idx === -1) return { error: 'Task not found' };
if (args.title) tasks[idx].title = args.title;
if (args.description !== undefined) tasks[idx].description = args.description;
if (args.priority) tasks[idx].priority = args.priority;
if (args.status) tasks[idx].status = args.status;
if (args.due !== undefined) tasks[idx].due = args.due;
if (args.tags) tasks[idx].tags = args.tags;
tasks[idx].updated = new Date().toISOString();
saveTasks(tasks);
return { success: true, task: tasks[idx], message: 'Task updated' };
}
case 'complete': {
const idx = tasks.findIndex(t => t.id === args.id);
if (idx === -1) return { error: 'Task not found' };
tasks[idx].status = 'completed';
tasks[idx].completedAt = new Date().toISOString();
saveTasks(tasks);
return { success: true, task: tasks[idx], message: 'Task completed' };
}
case 'delete': {
const idx = tasks.findIndex(t => t.id === args.id);
if (idx === -1) return { error: 'Task not found' };
const deleted = tasks.splice(idx, 1)[0];
saveTasks(tasks);
return { success: true, deleted, message: 'Task deleted' };
}
case 'clear_completed': {
const before = tasks.length;
tasks = tasks.filter(t => t.status !== 'completed');
saveTasks(tasks);
return { success: true, removed: before - tasks.length, remaining: tasks.length };
}
default:
return { error: 'Unknown action. Use: create, list, update, complete, delete, clear_completed' };
}`,
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
description: 'Action: create, list, update, complete, delete, clear_completed'
},
id: { type: 'string', description: 'Task ID (for update/complete/delete)' },
title: { type: 'string', description: 'Task title (for create/update)' },
description: { type: 'string', description: 'Task description' },
priority: { type: 'string', description: 'Priority: low, medium, high, urgent' },
status: { type: 'string', description: 'Filter/set status: pending, in_progress, completed' },
due: { type: 'string', description: 'Due date (ISO format)' },
tags: { type: 'array', description: 'Tags for categorization' },
tag: { type: 'string', description: 'Filter by tag (for list)' }
},
required: ['action']
}
},
{
id: 'js-memory-store',
name: 'Memory Store',
description:
'PERSISTENT MEMORY: Use this tool whenever the user asks you to remember something, recall memories, list what you remember, or forget something. Actions: store (save new memory), recall (retrieve memories), list (show all memories), forget (delete memory), clear (delete all). This gives you persistent memory across conversations.',
category: 'agentic',
language: 'javascript',
code: `// Memory Store - persistent key-value storage for agent context
const STORAGE_KEY = 'vessel_agent_memory';
const loadMemory = () => {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
} catch { return {}; }
};
const saveMemory = (mem) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(mem));
};
const action = args.action;
let memory = loadMemory();
switch (action) {
case 'store': {
const key = args.key;
const value = args.value;
const category = args.category || 'general';
// Validate required fields
if (!key) return { error: 'Key is required for store action' };
if (value === undefined || value === null) return { error: 'Value is required for store action' };
if (!memory[category]) memory[category] = {};
memory[category][key] = {
value,
stored: new Date().toISOString(),
accessCount: 0
};
saveMemory(memory);
return { success: true, key, category, value, message: 'Memory stored' };
}
case 'recall': {
const key = args.key;
const category = args.category;
if (category && key) {
const item = memory[category]?.[key];
if (!item) return { found: false, key, category };
item.accessCount++;
item.lastAccess = new Date().toISOString();
saveMemory(memory);
return { found: true, key, category, value: item.value, stored: item.stored };
}
if (category) {
// Return formatted entries for category (consistent with list)
const items = memory[category] || {};
const entries = Object.entries(items).map(([k, data]) => ({
key: k,
value: data.value,
stored: data.stored
}));
return { found: entries.length > 0, category, entries, count: entries.length };
}
if (key) {
// Search across all categories
for (const cat in memory) {
if (memory[cat][key]) {
memory[cat][key].accessCount++;
saveMemory(memory);
return { found: true, key, category: cat, value: memory[cat][key].value };
}
}
return { found: false, key };
}
// No key or category provided - return all memories (like list)
const allMemories = {};
for (const cat in memory) {
allMemories[cat] = Object.entries(memory[cat]).map(([k, data]) => ({
key: k,
value: data.value,
stored: data.stored
}));
}
return {
memories: allMemories,
totalCategories: Object.keys(memory).length,
totalEntries: Object.values(memory).reduce((sum, cat) => sum + Object.keys(cat).length, 0)
};
}
case 'list': {
const category = args.category;
if (category) {
const items = memory[category] || {};
const entries = Object.entries(items).map(([key, data]) => ({
key,
value: data.value,
stored: data.stored
}));
return {
category,
entries,
count: entries.length
};
}
// List all categories with their entries
const allMemories = {};
for (const cat in memory) {
allMemories[cat] = Object.entries(memory[cat]).map(([key, data]) => ({
key,
value: data.value,
stored: data.stored
}));
}
return {
memories: allMemories,
totalCategories: Object.keys(memory).length,
totalEntries: Object.values(memory).reduce((sum, cat) => sum + Object.keys(cat).length, 0)
};
}
case 'forget': {
const key = args.key;
const category = args.category;
if (category && key) {
if (memory[category]?.[key]) {
delete memory[category][key];
if (Object.keys(memory[category]).length === 0) delete memory[category];
saveMemory(memory);
return { success: true, forgotten: key, category };
}
return { error: 'Memory not found', key, category };
}
if (category) {
if (!memory[category]) {
return { error: 'Category not found', category };
}
const count = Object.keys(memory[category]).length;
delete memory[category];
saveMemory(memory);
return { success: true, forgotten: category, type: 'category', entriesRemoved: count };
}
return { error: 'Provide key and/or category to forget' };
}
case 'clear': {
const before = Object.keys(memory).length;
memory = {};
saveMemory(memory);
return { success: true, cleared: before, message: 'All memory cleared' };
}
default:
return { error: 'Unknown action. Use: store, recall, list, forget, clear' };
}`,
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
description:
'Required. Use "list" or "recall" to show memories, "store" to save new memory, "forget" to delete, "clear" to erase all'
},
key: { type: 'string', description: 'Unique identifier for the memory (e.g., "user_name", "favorite_color")' },
value: { type: 'string', description: 'The information to remember (required for store action)' },
category: { type: 'string', description: 'Optional grouping (e.g., "preferences", "facts", "context"). Defaults to "general"' }
},
required: ['action']
}
},
{
id: 'js-think-step-by-step',
name: 'Structured Thinking',
description:
'REASONING: Use for complex questions requiring step-by-step analysis. Helps you think through problems systematically before answering. Use when facing multi-part questions, logical puzzles, or decisions requiring careful thought.',
category: 'agentic',
language: 'javascript',
code: `// Structured Thinking - explicit step-by-step reasoning
const problem = args.problem;
const steps = args.steps || [];
const conclusion = args.conclusion;
const confidence = args.confidence || 'medium';
const analysis = {
problem: problem,
reasoning: {
steps: steps.map((step, i) => ({
step: i + 1,
thought: step,
type: step.toLowerCase().includes('assume') ? 'assumption' :
step.toLowerCase().includes('if') ? 'conditional' :
step.toLowerCase().includes('because') ? 'justification' :
step.toLowerCase().includes('therefore') ? 'inference' :
'observation'
})),
stepCount: steps.length
},
conclusion: conclusion,
confidence: confidence,
confidenceScore: confidence === 'high' ? 0.9 :
confidence === 'medium' ? 0.7 :
confidence === 'low' ? 0.4 : 0.5,
metadata: {
hasAssumptions: steps.some(s => s.toLowerCase().includes('assume')),
hasConditionals: steps.some(s => s.toLowerCase().includes('if')),
timestamp: new Date().toISOString()
}
};
// Add quality indicators
analysis.quality = {
hasMultipleSteps: steps.length >= 3,
hasConclusion: !!conclusion,
isWellStructured: steps.length >= 2 && !!conclusion,
suggestions: []
};
if (steps.length < 2) {
analysis.quality.suggestions.push('Consider breaking down into more steps');
}
if (!conclusion) {
analysis.quality.suggestions.push('Add a clear conclusion');
}
if (confidence === 'low') {
analysis.quality.suggestions.push('Identify what additional information would increase confidence');
}
return analysis;`,
parameters: {
type: 'object',
properties: {
problem: {
type: 'string',
description: 'The problem or question to reason about'
},
steps: {
type: 'array',
description: 'Array of reasoning steps, each a string explaining one step of thought'
},
conclusion: {
type: 'string',
description: 'The final conclusion reached'
},
confidence: {
type: 'string',
description: 'Confidence level: low, medium, high'
}
},
required: ['problem', 'steps']
}
},
{
id: 'js-decision-matrix',
name: 'Decision Matrix',
description:
'DECISION HELPER: Use when comparing multiple options, recommending choices, or evaluating trade-offs. Scores options against weighted criteria. Perfect for "which should I choose", "compare X vs Y", or recommendation requests.',
category: 'agentic',
language: 'javascript',
code: `// Decision Matrix - weighted multi-criteria decision analysis
const options = args.options || [];
const criteria = args.criteria || [];
const scores = args.scores || {};
if (options.length === 0) {
return { error: 'Provide at least one option' };
}
if (criteria.length === 0) {
return { error: 'Provide at least one criterion with name and weight' };
}
// Normalize weights
const totalWeight = criteria.reduce((sum, c) => sum + (c.weight || 1), 0);
const normalizedCriteria = criteria.map(c => ({
name: c.name,
weight: (c.weight || 1) / totalWeight,
originalWeight: c.weight || 1
}));
// Calculate weighted scores for each option
const results = options.map(option => {
let totalScore = 0;
const breakdown = [];
for (const criterion of normalizedCriteria) {
const score = scores[option]?.[criterion.name] ?? 5; // Default to 5/10
const weighted = score * criterion.weight;
totalScore += weighted;
breakdown.push({
criterion: criterion.name,
rawScore: score,
weight: Math.round(criterion.weight * 100) + '%',
weightedScore: Math.round(weighted * 100) / 100
});
}
return {
option,
totalScore: Math.round(totalScore * 100) / 100,
maxPossible: 10,
percentage: Math.round(totalScore * 10) + '%',
breakdown
};
});
// Sort by score
results.sort((a, b) => b.totalScore - a.totalScore);
// Identify winner and insights
const winner = results[0];
const runnerUp = results[1];
const margin = runnerUp ? Math.round((winner.totalScore - runnerUp.totalScore) * 100) / 100 : null;
return {
recommendation: winner.option,
confidence: margin > 1.5 ? 'high' : margin > 0.5 ? 'medium' : 'low',
margin: margin,
rankings: results,
criteria: normalizedCriteria.map(c => ({
name: c.name,
weight: Math.round(c.weight * 100) + '%'
})),
insight: margin && margin < 0.5 ?
'Options are very close - consider additional criteria or qualitative factors' :
margin && margin > 2 ?
\`\${winner.option} is a clear winner with significant margin\` :
'Decision is reasonably clear but review the breakdown for nuance'
};`,
parameters: {
type: 'object',
properties: {
options: {
type: 'array',
description: 'Array of option names to evaluate (e.g., ["Option A", "Option B"])'
},
criteria: {
type: 'array',
description: 'Array of criteria objects with name and weight (e.g., [{"name": "Cost", "weight": 3}, {"name": "Quality", "weight": 2}])'
},
scores: {
type: 'object',
description: 'Scores object: { "Option A": { "Cost": 8, "Quality": 7 }, "Option B": { "Cost": 6, "Quality": 9 } }'
}
},
required: ['options', 'criteria', 'scores']
}
},
{
id: 'js-project-planner',
name: 'Project Planner',
description:
'PROJECT BREAKDOWN: Use when planning projects, creating roadmaps, or breaking work into phases. Helps structure complex initiatives with tasks, dependencies, and milestones. Use for "help me plan", "break this down", or project planning requests.',
category: 'agentic',
language: 'javascript',
code: `// Project Planner - decompose projects into actionable plans
const projectName = args.project_name;
const goal = args.goal;
const phases = args.phases || [];
const constraints = args.constraints || [];
if (!projectName || !goal) {
return { error: 'Provide project_name and goal' };
}
const plan = {
project: projectName,
goal: goal,
created: new Date().toISOString(),
constraints: constraints,
phases: phases.map((phase, phaseIdx) => ({
id: \`phase-\${phaseIdx + 1}\`,
name: phase.name,
description: phase.description || '',
order: phaseIdx + 1,
tasks: (phase.tasks || []).map((task, taskIdx) => ({
id: \`\${phaseIdx + 1}.\${taskIdx + 1}\`,
title: task.title || task,
description: task.description || '',
dependencies: task.dependencies || [],
status: 'pending',
priority: task.priority || 'medium'
})),
deliverables: phase.deliverables || []
})),
summary: {
totalPhases: phases.length,
totalTasks: phases.reduce((sum, p) => sum + (p.tasks?.length || 0), 0),
hasConstraints: constraints.length > 0
}
};
// Identify critical path (tasks with most dependents)
const allTasks = plan.phases.flatMap(p => p.tasks);
const dependencyCounts = {};
allTasks.forEach(t => {
t.dependencies.forEach(dep => {
dependencyCounts[dep] = (dependencyCounts[dep] || 0) + 1;
});
});
plan.criticalTasks = Object.entries(dependencyCounts)
.filter(([_, count]) => count > 1)
.map(([id, count]) => ({ taskId: id, dependentCount: count }))
.sort((a, b) => b.dependentCount - a.dependentCount);
// Generate next actions (tasks with no pending dependencies)
const completedTasks = new Set();
plan.nextActions = allTasks
.filter(t => t.dependencies.every(d => completedTasks.has(d)))
.slice(0, 5)
.map(t => ({ id: t.id, title: t.title, phase: t.id.split('.')[0] }));
// Validation
plan.validation = {
isValid: phases.length > 0 && plan.summary.totalTasks > 0,
warnings: []
};
if (phases.length === 0) {
plan.validation.warnings.push('No phases defined');
}
if (plan.summary.totalTasks === 0) {
plan.validation.warnings.push('No tasks defined');
}
if (constraints.length === 0) {
plan.validation.warnings.push('Consider adding constraints (time, budget, resources)');
}
return plan;`,
parameters: {
type: 'object',
properties: {
project_name: {
type: 'string',
description: 'Name of the project'
},
goal: {
type: 'string',
description: 'The main goal or outcome of the project'
},
phases: {
type: 'array',
description: 'Array of phase objects: [{ name, description, tasks: [{ title, dependencies, priority }], deliverables }]'
},
constraints: {
type: 'array',
description: 'Array of constraints (e.g., ["Budget: $10k", "Timeline: 2 weeks"])'
}
},
required: ['project_name', 'goal']
}
}
];

View File

@@ -16,6 +16,12 @@ export interface Conversation {
messageCount: number;
/** Optional system prompt ID for this conversation (null = use global default) */
systemPromptId?: string | null;
/** Optional project ID this conversation belongs to */
projectId?: string | null;
/** Auto-generated conversation summary for cross-chat context */
summary?: string | null;
/** Timestamp when summary was last updated */
summaryUpdatedAt?: Date | null;
}
/** Full conversation including message tree and navigation state */

View File

@@ -120,6 +120,9 @@ export interface ChatSettings {
/** Auto-compact settings for context management */
autoCompact?: AutoCompactSettings;
/** Embedding model for semantic search (e.g., 'nomic-embed-text') */
embeddingModel?: string;
}
/**

View File

@@ -7,14 +7,15 @@
import '../app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { chatState, conversationsState, modelsState, uiState, promptsState, versionState } from '$lib/stores';
import { chatState, conversationsState, modelsState, uiState, promptsState, versionState, projectsState } from '$lib/stores';
import { getAllConversations } from '$lib/storage';
import { syncManager } from '$lib/backend';
import { keyboardShortcuts, getShortcuts } from '$lib/utils';
import { scheduleMigration } from '$lib/services/chat-index-migration.js';
import Sidenav from '$lib/components/layout/Sidenav.svelte';
import TopNav from '$lib/components/layout/TopNav.svelte';
import ModelSelect from '$lib/components/layout/ModelSelect.svelte';
import { ToastContainer, ShortcutsModal, SearchModal } from '$lib/components/shared';
import { ToastContainer, ShortcutsModal } from '$lib/components/shared';
import UpdateBanner from '$lib/components/shared/UpdateBanner.svelte';
import type { LayoutData } from './$types';
@@ -30,9 +31,6 @@
// Sidenav width constant
const SIDENAV_WIDTH = 280;
// Search modal state
let showSearchModal = $state(false);
// Shortcuts modal state
let showShortcutsModal = $state(false);
@@ -66,6 +64,12 @@
// Load conversations from IndexedDB
loadConversations();
// Load projects from IndexedDB
projectsState.load();
// Schedule background migration for chat indexing (runs after 5 seconds)
scheduleMigration(5000);
return () => {
uiState.destroy();
syncManager.destroy();
@@ -90,12 +94,12 @@
}
});
// Search (Cmd/Ctrl + K) - opens global search modal
// Search (Cmd/Ctrl + K) - navigates to search page
keyboardShortcuts.register({
...SHORTCUTS.SEARCH,
preventDefault: true,
handler: () => {
showSearchModal = true;
goto('/search');
}
});
@@ -182,6 +186,3 @@
<!-- Keyboard shortcuts help -->
<ShortcutsModal isOpen={showShortcutsModal} onClose={() => (showShortcutsModal = false)} />
<!-- Global search modal -->
<SearchModal isOpen={showSearchModal} onClose={() => (showSearchModal = false)} />

View File

@@ -65,7 +65,9 @@
async function retrieveRagContext(query: string): Promise<string | null> {
if (!ragEnabled || !hasKnowledgeBase) return null;
try {
const results = await searchSimilar(query, 3, 0.5);
// Search global documents only (null projectId) for home page
// Lower threshold (0.3) to catch more relevant results
const results = await searchSimilar(query, { topK: 5, threshold: 0.3, projectId: null });
if (results.length === 0) return null;
return formatResultsAsContext(results);
} catch {

View File

@@ -4,7 +4,8 @@
* Displays an existing conversation with chat window
*/
import { goto } from '$app/navigation';
import { goto, replaceState } from '$app/navigation';
import { page } from '$app/stores';
import { chatState, conversationsState, modelsState } from '$lib/stores';
import { getConversationFull } from '$lib/storage';
import ChatWindow from '$lib/components/chat/ChatWindow.svelte';
@@ -20,6 +21,17 @@
let currentConversationId = $state<string | null>(null);
let isLoading = $state(false);
// Extract first message from data and clear from URL
let initialMessage = $state<string | null>(data.firstMessage);
$effect(() => {
// Clear firstMessage from URL to keep it clean
if (data.firstMessage && $page.url.searchParams.has('firstMessage')) {
const url = new URL($page.url);
url.searchParams.delete('firstMessage');
replaceState(url, {});
}
});
/**
* Load conversation into chat state when URL changes
*/
@@ -135,6 +147,6 @@
</div>
{:else}
<!-- Chat window in conversation mode -->
<ChatWindow mode="conversation" {conversation} />
<ChatWindow mode="conversation" {conversation} {initialMessage} />
{/if}
</div>

View File

@@ -6,7 +6,7 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
export const load: PageLoad = async ({ params, url }) => {
const { id } = params;
// Validate that ID looks like a UUID
@@ -18,10 +18,11 @@ export const load: PageLoad = async ({ params }) => {
});
}
// TODO: In the future, load conversation from IndexedDB here
// For now, just return the ID and let the page component handle state
// Extract firstMessage query param (for new chats from project page)
const firstMessage = url.searchParams.get('firstMessage') || null;
return {
conversationId: id
conversationId: id,
firstMessage
};
};

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
redirect(301, '/settings?tab=knowledge');
};

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
redirect(301, '/settings?tab=models');
};

View File

@@ -0,0 +1,726 @@
<script lang="ts">
/**
* Project detail page
* Shows project header, new chat input, conversations, and files
*/
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { projectsState, conversationsState, modelsState, toastState, chatState } from '$lib/stores';
import { createConversation as createStoredConversation } from '$lib/storage';
import { getProjectStats, getProjectLinks, type ProjectLink } from '$lib/storage/projects.js';
import {
listDocuments,
addDocumentAsync,
deleteDocument,
resetStuckDocuments,
DEFAULT_EMBEDDING_MODEL,
EMBEDDING_MODELS
} from '$lib/memory';
import type { StoredDocument } from '$lib/storage/db';
import ProjectModal from '$lib/components/projects/ProjectModal.svelte';
import { searchProjectChatHistory, type ChatSearchResult } from '$lib/services/chat-indexer.js';
import { ConfirmDialog } from '$lib/components/shared';
// Get project ID from URL
const projectId = $derived($page.params.id);
// Project data
const project = $derived.by(() => {
return projectsState.projects.find(p => p.id === projectId) || null;
});
// Project conversations
const projectConversations = $derived.by(() => {
if (!projectId) return [];
return conversationsState.forProject(projectId);
});
// State
let searchQuery = $state('');
let newChatMessage = $state('');
let isCreatingChat = $state(false);
let showProjectModal = $state(false);
let links = $state<ProjectLink[]>([]);
let documents = $state<StoredDocument[]>([]);
let isLoadingDocs = $state(false);
let selectedEmbeddingModel = $state(DEFAULT_EMBEDDING_MODEL);
let activeTab = $state<'chats' | 'files' | 'links'>('chats');
let fileInput: HTMLInputElement;
let dragOver = $state(false);
let isSearching = $state(false);
let searchResults = $state<ChatSearchResult[]>([]);
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let deleteDocConfirm = $state<{ show: boolean; doc: StoredDocument | null }>({ show: false, doc: null });
// Map of conversationId -> best matching snippet from search
const searchSnippetMap = $derived.by(() => {
const map = new Map<string, { content: string; similarity: number }>();
for (const result of searchResults) {
const existing = map.get(result.conversationId);
// Keep the result with highest similarity
if (!existing || result.similarity > existing.similarity) {
map.set(result.conversationId, {
content: result.content.slice(0, 200) + (result.content.length > 200 ? '...' : ''),
similarity: result.similarity
});
}
}
return map;
});
// Get unique conversation IDs from search results, ordered by best match
const searchConversationIds = $derived.by(() => {
const idScores = new Map<string, number>();
for (const result of searchResults) {
const existing = idScores.get(result.conversationId) ?? 0;
if (result.similarity > existing) {
idScores.set(result.conversationId, result.similarity);
}
}
// Sort by score descending
return [...idScores.entries()]
.sort((a, b) => b[1] - a[1])
.map(([id]) => id);
});
// Filtered conversations based on search (semantic search when query present)
const filteredConversations = $derived.by(() => {
if (!searchQuery.trim()) return projectConversations;
// If we have semantic search results, filter and order by them
if (searchResults.length > 0) {
return searchConversationIds
.map(id => projectConversations.find(c => c.id === id))
.filter((c): c is NonNullable<typeof c> => c !== undefined);
}
// Fallback to title search while waiting for semantic results
const query = searchQuery.toLowerCase();
return projectConversations.filter(c =>
c.title.toLowerCase().includes(query)
);
});
// Debounced semantic search
async function handleSearch() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
if (!searchQuery.trim() || !projectId) {
searchResults = [];
isSearching = false;
return;
}
isSearching = true;
const currentProjectId = projectId; // Capture for async closure
searchDebounceTimer = setTimeout(async () => {
try {
const results = await searchProjectChatHistory(currentProjectId, searchQuery, undefined, 20, 0.15);
searchResults = results;
} catch (error) {
console.error('[ProjectSearch] Semantic search failed:', error);
searchResults = [];
} finally {
isSearching = false;
}
}, 300);
}
// Track if component is mounted
let isMounted = false;
// Load project data on mount
onMount(() => {
isMounted = true;
// Use an async IIFE to handle the async logic
(async () => {
// Wait for projects to be loaded from IndexedDB
let attempts = 0;
while (!projectsState.hasLoaded && attempts < 50 && isMounted) {
await new Promise(r => setTimeout(r, 100));
attempts++;
}
if (!isMounted) return; // Component unmounted while waiting
// Now check if project exists
const foundProject = projectsState.projects.find(p => p.id === projectId);
if (!foundProject) {
goto('/');
return;
}
await loadProjectData();
})();
return () => {
isMounted = false;
};
});
async function loadProjectData() {
if (!projectId) return;
// Load links
const linksResult = await getProjectLinks(projectId);
if (linksResult.success) {
links = linksResult.data;
}
// Load documents filtered by projectId
isLoadingDocs = true;
try {
// Reset any stuck documents (interrupted by page refresh/HMR)
const resetCount = await resetStuckDocuments();
if (resetCount > 0) {
toastState.warning(`${resetCount} document(s) were interrupted - please re-upload`);
}
const allDocs = await listDocuments();
// Filter documents by projectId - strict equality
documents = allDocs.filter(d => d.projectId === projectId);
} catch (err) {
console.error('Failed to load documents:', err);
documents = [];
} finally {
isLoadingDocs = false;
}
}
async function handleCreateChat() {
if (!newChatMessage.trim() || isCreatingChat) return;
const model = modelsState.selectedId;
if (!model) {
toastState.error('No model selected');
return;
}
isCreatingChat = true;
try {
// Generate title from message
const title = generateTitle(newChatMessage);
// Create conversation with projectId
const result = await createStoredConversation({
title,
model,
isPinned: false,
isArchived: false,
projectId
});
if (result.success) {
// Add to conversations state
conversationsState.add(result.data);
// Store the message content before clearing
const messageContent = newChatMessage;
newChatMessage = '';
// Navigate to the new chat
// The chat page will handle the first message
goto(`/chat/${result.data.id}?firstMessage=${encodeURIComponent(messageContent)}`);
} else {
toastState.error('Failed to create chat');
}
} catch {
toastState.error('Failed to create chat');
} finally {
isCreatingChat = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCreateChat();
}
}
function generateTitle(content: string): string {
const firstLine = content.split('\n')[0].trim();
const firstSentence = firstLine.split(/[.!?]/)[0].trim();
if (firstSentence.length <= 50) {
return firstSentence || 'New Chat';
}
return firstSentence.substring(0, 47) + '...';
}
function formatDate(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
});
}
// File upload handlers
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await processFiles(Array.from(input.files));
}
input.value = '';
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
dragOver = false;
if (event.dataTransfer?.files) {
await processFiles(Array.from(event.dataTransfer.files));
}
}
async function processFiles(files: File[]) {
if (!projectId) {
toastState.error('Project ID not available');
return;
}
for (const file of files) {
try {
const content = await file.text();
if (!content.trim()) {
toastState.warning(`File "${file.name}" is empty, skipping`);
continue;
}
// Add document async - stores immediately, embeds in background
await addDocumentAsync(file.name, content, file.type || 'text/plain', {
embeddingModel: selectedEmbeddingModel,
projectId: projectId,
onComplete: (doc) => {
toastState.success(`Embeddings ready for "${doc.name}"`);
loadProjectData(); // Refresh to show updated status
},
onError: (error) => {
toastState.error(`Embedding failed for "${file.name}": ${error.message}`);
loadProjectData(); // Refresh to show failed status
}
});
toastState.info(`Added "${file.name}" - generating embeddings...`);
} catch (error) {
console.error(`Failed to process ${file.name}:`, error);
const message = error instanceof Error ? error.message : 'Unknown error';
toastState.error(`Failed to add "${file.name}": ${message}`);
}
}
// Refresh immediately to show pending documents
await loadProjectData();
}
function handleDeleteDocumentClick(doc: StoredDocument) {
deleteDocConfirm = { show: true, doc };
}
async function confirmDeleteDocument() {
if (!deleteDocConfirm.doc) return;
const doc = deleteDocConfirm.doc;
deleteDocConfirm = { show: false, doc: null };
try {
await deleteDocument(doc.id);
toastState.success(`Deleted "${doc.name}"`);
await loadProjectData();
} catch {
toastState.error('Failed to delete document');
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
</script>
<svelte:head>
<title>{project?.name || 'Project'} - Vessel</title>
</svelte:head>
{#if project}
<div class="flex h-full flex-col overflow-hidden bg-theme-primary">
<!-- Project Header -->
<div class="border-b border-theme px-6 py-4">
<div class="mx-auto max-w-4xl">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<!-- Folder icon with project color -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
viewBox="0 0 20 20"
fill={project.color || '#10b981'}
>
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</svg>
<div>
<h1 class="text-xl font-semibold text-theme-primary">{project.name}</h1>
{#if project.description}
<p class="text-sm text-theme-muted">{project.description}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-3">
<!-- Stats badge -->
<div class="flex items-center gap-2 rounded-full bg-theme-secondary px-3 py-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
</svg>
<span class="text-sm text-theme-muted">{projectConversations.length} chats</span>
</div>
<div class="flex items-center gap-2 rounded-full bg-theme-secondary px-3 py-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<span class="text-sm text-theme-muted">{documents.length} files</span>
</div>
<!-- Settings button -->
<button
type="button"
onclick={() => showProjectModal = true}
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
title="Project settings"
>
<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.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-4xl px-6 py-6">
<!-- New Chat Input -->
<div class="mb-6 rounded-xl border border-theme bg-theme-secondary p-4">
<textarea
bind:value={newChatMessage}
onkeydown={handleKeydown}
placeholder="New chat in {project.name}"
rows="2"
class="w-full resize-none bg-transparent text-theme-primary placeholder-theme-muted focus:outline-none"
></textarea>
<div class="mt-3 flex items-center justify-between">
<div class="flex items-center gap-2 text-sm text-theme-muted">
<span>Model: {modelsState.selectedId || 'None selected'}</span>
</div>
<button
type="button"
onclick={handleCreateChat}
disabled={!newChatMessage.trim() || isCreatingChat || !modelsState.selectedId}
class="rounded-full bg-emerald-600 p-2 text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
>
{#if isCreatingChat}
<svg class="h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
</svg>
{/if}
</button>
</div>
</div>
<!-- Tabs -->
<div class="mb-4 border-b border-theme">
<div class="flex gap-6">
<button
type="button"
onclick={() => activeTab = 'chats'}
class="relative pb-3 text-sm font-medium transition-colors {activeTab === 'chats' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
>
Chats ({projectConversations.length})
{#if activeTab === 'chats'}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
{/if}
</button>
<button
type="button"
onclick={() => activeTab = 'files'}
class="relative pb-3 text-sm font-medium transition-colors {activeTab === 'files' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
>
Files ({documents.length})
{#if activeTab === 'files'}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
{/if}
</button>
<button
type="button"
onclick={() => activeTab = 'links'}
class="relative pb-3 text-sm font-medium transition-colors {activeTab === 'links' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
>
Links ({links.length})
{#if activeTab === 'links'}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
{/if}
</button>
</div>
</div>
<!-- Tab Content -->
{#if activeTab === 'chats'}
<!-- Search -->
<div class="mb-4">
<div class="relative">
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
type="text"
bind:value={searchQuery}
oninput={handleSearch}
placeholder="Search chats in project (semantic search)..."
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-10 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
/>
{#if isSearching}
<svg class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-theme-muted" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
<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" />
</svg>
{/if}
</div>
</div>
<!-- Conversations List -->
{#if filteredConversations.length === 0}
<div class="py-12 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto mb-3 h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
</svg>
{#if searchQuery}
<p class="text-sm text-theme-muted">No chats match your search</p>
{:else}
<p class="text-sm text-theme-muted">No chats in this project yet</p>
<p class="mt-1 text-xs text-theme-muted">Start a new chat above to get started</p>
{/if}
</div>
{:else}
<div class="space-y-2">
{#each filteredConversations as conversation (conversation.id)}
{@const matchSnippet = searchSnippetMap.get(conversation.id)}
<a
href="/chat/{conversation.id}"
class="block rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:bg-theme-tertiary"
>
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<h3 class="truncate font-medium text-theme-primary">
{conversation.title || 'Untitled'}
</h3>
{#if matchSnippet}
<!-- Show matching content from semantic search -->
<div class="mt-2 rounded-md bg-emerald-500/10 px-3 py-2">
<div class="mb-1 flex items-center gap-1.5 text-[10px] font-medium uppercase text-emerald-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
Match ({Math.round(matchSnippet.similarity * 100)}%)
</div>
<p class="line-clamp-2 text-sm text-theme-secondary">
{matchSnippet.content}
</p>
</div>
{:else if conversation.summary}
<p class="mt-1 line-clamp-2 text-sm text-theme-muted">
{conversation.summary}
</p>
{/if}
</div>
<span class="ml-4 shrink-0 text-xs text-theme-muted">
{formatDate(conversation.updatedAt)}
</span>
</div>
</a>
{/each}
</div>
{/if}
{:else if activeTab === 'files'}
<!-- Embedding Model Selector -->
<div class="mb-4 flex items-center justify-between">
<p class="text-sm text-theme-muted">Embedding Model</p>
<select
bind:value={selectedEmbeddingModel}
class="rounded-md border border-theme bg-theme-tertiary px-3 py-1.5 text-sm text-theme-primary focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
>
{#each EMBEDDING_MODELS as model}
<option value={model}>{model}</option>
{/each}
</select>
</div>
<!-- File Upload Zone -->
<div
class="mb-4 rounded-xl border-2 border-dashed border-theme p-8 text-center transition-colors {dragOver ? 'border-emerald-500 bg-emerald-500/10' : 'hover:border-emerald-500/50'}"
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
ondragleave={() => dragOver = false}
ondrop={handleDrop}
>
<input
bind:this={fileInput}
type="file"
multiple
accept=".txt,.md,.json,.csv,.xml,.html,.css,.js,.ts,.py,.go,.rs,.java,.c,.cpp,.h"
onchange={handleFileSelect}
class="hidden"
/>
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto mb-3 h-10 w-10 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
</svg>
<p class="text-sm text-theme-muted">
Drag & drop files here, or
<button type="button" onclick={() => fileInput.click()} class="text-emerald-500 hover:text-emerald-400">browse</button>
</p>
<p class="mt-1 text-xs text-theme-muted">
Text files, code, markdown, JSON, etc.
</p>
</div>
<!-- Files List -->
{#if documents.length === 0}
<div class="py-8 text-center">
<p class="text-sm text-theme-muted">No files in this project</p>
</div>
{:else}
<div class="space-y-2">
{#each documents as doc (doc.id)}
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-secondary p-3">
<div class="flex items-center gap-3">
<!-- Status indicator -->
{#if doc.embeddingStatus === 'pending' || doc.embeddingStatus === 'processing'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 animate-spin text-yellow-500" 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>
{:else if doc.embeddingStatus === 'failed'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-red-500" 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>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
{/if}
<div>
<p class="text-sm font-medium text-theme-primary">{doc.name}</p>
<p class="text-xs text-theme-muted">
{formatSize(doc.size)}
{#if doc.embeddingStatus === 'pending'}
<span class="ml-2 text-yellow-500">• Queued</span>
{:else if doc.embeddingStatus === 'processing'}
<span class="ml-2 text-yellow-500">• Generating embeddings...</span>
{:else if doc.embeddingStatus === 'failed'}
<span class="ml-2 text-red-500">• Embedding failed</span>
{/if}
</p>
</div>
</div>
<button
type="button"
onclick={() => handleDeleteDocumentClick(doc)}
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.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 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
{:else if activeTab === 'links'}
<!-- Links List -->
{#if links.length === 0}
<div class="py-12 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto mb-3 h-10 w-10 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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>
<p class="text-sm text-theme-muted">No reference links</p>
<p class="mt-1 text-xs text-theme-muted">Add links in project settings</p>
<button
type="button"
onclick={() => showProjectModal = true}
class="mt-3 text-sm text-emerald-500 hover:text-emerald-400"
>
Open settings
</button>
</div>
{:else}
<div class="space-y-2">
{#each links as link (link.id)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="block rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:bg-theme-tertiary"
>
<div class="flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" 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>
<div class="min-w-0 flex-1">
<p class="font-medium text-theme-primary">{link.title}</p>
{#if link.description}
<p class="mt-0.5 text-sm text-theme-muted">{link.description}</p>
{/if}
<p class="mt-1 truncate text-xs text-emerald-500">{link.url}</p>
</div>
</div>
</a>
{/each}
</div>
{/if}
{/if}
</div>
</div>
</div>
{:else}
<!-- Loading / Not Found -->
<div class="flex h-full items-center justify-center">
<div class="text-center">
<p class="text-theme-muted">Loading project...</p>
</div>
</div>
{/if}
<!-- Project Modal -->
<ProjectModal
isOpen={showProjectModal}
onClose={() => showProjectModal = false}
{projectId}
onUpdate={() => loadProjectData()}
/>
<!-- Delete Document Confirm -->
<ConfirmDialog
isOpen={deleteDocConfirm.show}
title="Delete Document"
message={`Delete "${deleteDocConfirm.doc?.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={confirmDeleteDocument}
onCancel={() => (deleteDocConfirm = { show: false, doc: null })}
/>

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
redirect(301, '/settings?tab=prompts');
};

View File

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

View File

@@ -0,0 +1,378 @@
<script lang="ts">
/**
* Global Search Page
* Full-page search with tabs for titles, messages, and semantic search
*/
import { page } from '$app/stores';
import { goto, replaceState } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { searchConversations, searchMessages, type MessageSearchResult } from '$lib/storage';
import { conversationsState, settingsState } from '$lib/stores';
import { searchChatHistory, type ChatSearchResult } from '$lib/services/chat-indexer.js';
import type { Conversation } from '$lib/types/conversation';
// Get query from URL
let searchQuery = $state($page.url.searchParams.get('query') || '');
let activeTab = $state<'titles' | 'messages' | 'semantic'>('semantic');
let isSearching = $state(false);
// Results
let titleResults = $state<Conversation[]>([]);
let messageResults = $state<MessageSearchResult[]>([]);
let semanticResults = $state<ChatSearchResult[]>([]);
// Debounce timer
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Input ref for autofocus
let inputElement: HTMLInputElement;
// Flag to prevent URL sync while actively typing/searching
let isTyping = $state(false);
let typingTimeout: ReturnType<typeof setTimeout> | null = null;
// Update URL when query changes (using SvelteKit's replaceState)
function updateUrl(query: string) {
const url = new URL($page.url);
if (query) {
url.searchParams.set('query', query);
} else {
url.searchParams.delete('query');
}
replaceState(url, {});
}
// Mark as typing (to prevent URL sync from overwriting input)
function markTyping() {
isTyping = true;
if (typingTimeout) clearTimeout(typingTimeout);
// Stop considering as "typing" after 1 second of no input
typingTimeout = setTimeout(() => {
isTyping = false;
}, 1000);
}
// Perform search with proper debouncing
async function performSearch() {
markTyping(); // Prevent URL sync from overwriting while typing
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
const query = searchQuery.trim();
if (!query) {
titleResults = [];
messageResults = [];
semanticResults = [];
isSearching = false;
return;
}
// Debounce to avoid excessive API calls while typing
debounceTimer = setTimeout(async () => {
// Capture query at search time to avoid race conditions
const currentQuery = searchQuery.trim();
if (!currentQuery) return;
isSearching = true;
try {
const [titlesResult, messagesResult, semanticSearchResults] = await Promise.all([
searchConversations(currentQuery),
searchMessages(currentQuery, { limit: 50 }),
searchChatHistory(currentQuery, {
topK: 50,
threshold: 0.15,
embeddingModel: settingsState.embeddingModel
})
]);
// Only update results AND URL if query hasn't changed during search
if (searchQuery.trim() === currentQuery) {
if (titlesResult.success) {
titleResults = titlesResult.data;
}
if (messagesResult.success) {
messageResults = messagesResult.data;
}
semanticResults = semanticSearchResults;
// Update URL only after successful search with unchanged query
updateUrl(currentQuery);
}
} catch (error) {
console.error('[Search] Search error:', error);
} finally {
isSearching = false;
}
}, 300);
}
// Navigate to conversation
function navigateToConversation(conversationId: string) {
goto(`/chat/${conversationId}`);
}
// Get conversation title for message results
function getConversationTitle(conversationId: string): string {
const conversation = conversationsState.find(conversationId);
return conversation?.title ?? 'Unknown conversation';
}
// Get snippet around match
function getSnippet(content: string, matchIndex: number, query: string): string {
const start = Math.max(0, matchIndex - 40);
const end = Math.min(content.length, matchIndex + query.length + 60);
let snippet = content.slice(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < content.length) snippet = snippet + '...';
return snippet;
}
// Format date
function formatDate(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
// Run search on mount if query exists
onMount(() => {
if (searchQuery) {
performSearch();
}
// Focus input
inputElement?.focus();
});
// Watch for URL changes (back/forward navigation only)
$effect(() => {
const urlQuery = $page.url.searchParams.get('query') || '';
// Only react to external URL changes when not actively typing
untrack(() => {
if (!isTyping && urlQuery !== searchQuery) {
searchQuery = urlQuery;
if (urlQuery) {
performSearch();
}
}
});
});
</script>
<svelte:head>
<title>{searchQuery ? `Search: ${searchQuery}` : 'Search'} - Vessel</title>
</svelte:head>
<div class="flex h-full flex-col overflow-hidden bg-theme-primary">
<!-- Search Header -->
<div class="border-b border-theme px-6 py-4">
<div class="mx-auto max-w-4xl">
<!-- Search Input -->
<div class="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
class="absolute left-4 top-1/2 h-5 w-5 -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 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<input
bind:this={inputElement}
bind:value={searchQuery}
oninput={performSearch}
type="text"
placeholder="Search conversations, messages, or use semantic search..."
class="w-full rounded-xl border border-theme bg-slate-800 py-3 pl-12 pr-12 text-lg text-white placeholder-slate-400 focus:border-violet-500/50 focus:outline-none focus:ring-2 focus:ring-violet-500/50"
/>
{#if isSearching}
<svg class="absolute right-4 top-1/2 h-5 w-5 -translate-y-1/2 animate-spin text-theme-muted" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
<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" />
</svg>
{:else if searchQuery}
<button
type="button"
onclick={() => { searchQuery = ''; titleResults = []; messageResults = []; semanticResults = []; updateUrl(''); }}
class="absolute right-4 top-1/2 -translate-y-1/2 rounded p-1 text-theme-muted hover:text-theme-primary"
>
<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>
{/if}
</div>
<!-- Tabs -->
<div class="mt-4 flex gap-2">
<button
type="button"
onclick={() => (activeTab = 'semantic')}
class="rounded-full px-4 py-1.5 text-sm font-medium transition-colors {activeTab === 'semantic'
? 'bg-emerald-600 text-white'
: 'bg-theme-secondary text-theme-muted hover:text-theme-primary'}"
>
Semantic
{#if semanticResults.length > 0}
<span class="ml-1.5 rounded-full bg-white/20 px-1.5 py-0.5 text-xs">{semanticResults.length}</span>
{/if}
</button>
<button
type="button"
onclick={() => (activeTab = 'titles')}
class="rounded-full px-4 py-1.5 text-sm font-medium transition-colors {activeTab === 'titles'
? 'bg-violet-600 text-white'
: 'bg-theme-secondary text-theme-muted hover:text-theme-primary'}"
>
Titles
{#if titleResults.length > 0}
<span class="ml-1.5 rounded-full bg-white/20 px-1.5 py-0.5 text-xs">{titleResults.length}</span>
{/if}
</button>
<button
type="button"
onclick={() => (activeTab = 'messages')}
class="rounded-full px-4 py-1.5 text-sm font-medium transition-colors {activeTab === 'messages'
? 'bg-violet-600 text-white'
: 'bg-theme-secondary text-theme-muted hover:text-theme-primary'}"
>
Messages
{#if messageResults.length > 0}
<span class="ml-1.5 rounded-full bg-white/20 px-1.5 py-0.5 text-xs">{messageResults.length}</span>
{/if}
</button>
</div>
</div>
</div>
<!-- Results -->
<div class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-4xl px-6 py-6">
{#if !searchQuery.trim()}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-20 text-theme-muted">
<svg class="mb-4 h-16 w-16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<p class="text-lg">Start typing to search...</p>
<p class="mt-2 text-sm">Search across all your conversations using AI-powered semantic search</p>
</div>
{:else if activeTab === 'semantic'}
{#if semanticResults.length === 0 && !isSearching}
<div class="py-12 text-center text-theme-muted">
<p>No semantic matches found for "{searchQuery}"</p>
<p class="mt-2 text-sm">Semantic search uses AI embeddings to find similar content</p>
</div>
{:else}
<div class="space-y-3">
{#each semanticResults as result}
<button
type="button"
onclick={() => navigateToConversation(result.conversationId)}
class="block w-full rounded-xl border border-theme bg-theme-secondary p-4 text-left transition-colors hover:bg-theme-tertiary"
>
<div class="mb-2 flex items-center gap-2">
<span class="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-400">
{Math.round(result.similarity * 100)}% match
</span>
<span class="truncate text-sm text-theme-muted">
{result.conversationTitle}
</span>
</div>
<p class="line-clamp-3 text-sm text-theme-secondary">
{result.content.slice(0, 300)}{result.content.length > 300 ? '...' : ''}
</p>
</button>
{/each}
</div>
{/if}
{:else if activeTab === 'titles'}
{#if titleResults.length === 0 && !isSearching}
<div class="py-12 text-center text-theme-muted">
No conversations found matching "{searchQuery}"
</div>
{:else}
<div class="space-y-2">
{#each titleResults as result}
<button
type="button"
onclick={() => navigateToConversation(result.id)}
class="flex w-full items-center gap-4 rounded-xl border border-theme bg-theme-secondary p-4 text-left transition-colors hover:bg-theme-tertiary"
>
<svg class="h-5 w-5 flex-shrink-0 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-theme-primary">{result.title}</p>
<p class="text-sm text-theme-muted">
{result.messageCount} messages · {result.model}
</p>
</div>
<span class="text-sm text-theme-muted">{formatDate(result.updatedAt)}</span>
{#if result.isPinned}
<svg class="h-4 w-4 text-emerald-500" fill="currentColor" viewBox="0 0 24 24">
<path 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 0111.186 0z" />
</svg>
{/if}
</button>
{/each}
</div>
{/if}
{:else if activeTab === 'messages'}
{#if messageResults.length === 0 && !isSearching}
<div class="py-12 text-center text-theme-muted">
No messages found matching "{searchQuery}"
</div>
{:else}
<div class="space-y-3">
{#each messageResults as result}
<button
type="button"
onclick={() => navigateToConversation(result.conversationId)}
class="block w-full rounded-xl border border-theme bg-theme-secondary p-4 text-left transition-colors hover:bg-theme-tertiary"
>
<div class="mb-2 flex items-center gap-2">
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase {result.role === 'user'
? 'bg-blue-500/20 text-blue-400'
: 'bg-emerald-500/20 text-emerald-400'}"
>
{result.role}
</span>
<span class="truncate text-sm text-theme-muted">
{getConversationTitle(result.conversationId)}
</span>
</div>
<p class="line-clamp-2 text-sm text-theme-secondary">
{getSnippet(result.content, result.matchIndex, searchQuery)}
</p>
</button>
{/each}
</div>
{/if}
{/if}
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
redirect(301, '/settings?tab=tools');
};

View File

@@ -103,6 +103,80 @@ prompt_yes_no() {
[[ "$response" =~ ^[Yy]$ ]]
}
# =============================================================================
# Version & Release Notes
# =============================================================================
GITHUB_RELEASES_URL="https://api.github.com/repos/VikingOwl91/vessel/releases"
GITHUB_RELEASES_PAGE="https://github.com/VikingOwl91/vessel/releases"
get_installed_version() {
if [[ -f "backend/cmd/server/main.go" ]]; then
grep -oP 'Version\s*=\s*"\K[^"]+' backend/cmd/server/main.go 2>/dev/null || echo "unknown"
else
echo "unknown"
fi
}
version_gt() {
# Returns 0 (true) if $1 > $2 using version sort
[[ "$(printf '%s\n' "$1" "$2" | sort -V | head -n1)" != "$1" ]]
}
show_release_notes() {
local old_version="$1"
local new_version="$2"
# Skip if versions are the same or unknown
if [[ "$old_version" == "$new_version" ]] || [[ "$old_version" == "unknown" ]]; then
return
fi
# Check if jq is available
if ! check_command jq; then
echo ""
echo -e "${CYAN}📋 What's New:${NC}"
echo -e " View release notes at: ${CYAN}${GITHUB_RELEASES_PAGE}${NC}"
echo ""
return
fi
# Fetch releases from GitHub API
local releases
releases=$(curl -s --connect-timeout 5 "$GITHUB_RELEASES_URL" 2>/dev/null) || {
return
}
# Check if we got valid JSON
if ! echo "$releases" | jq -e '.' &>/dev/null; then
return
fi
# Filter releases between old and new version, format output
local notes
notes=$(echo "$releases" | jq -r --arg old "$old_version" --arg new "$new_version" '
.[] |
select(.draft == false and .prerelease == false) |
(.tag_name | ltrimstr("v")) as $ver |
select(
($ver != $old) and
([$ver, $old] | sort_by(split(".") | map(tonumber? // 0)) | .[0] == $old) and
([$ver, $new] | sort_by(split(".") | map(tonumber? // 0)) | .[1] == $new or $ver == $new)
) |
"───────────────────────────────────────────────────────────\n" +
"📦 " + .tag_name + " - " + (.name // "Release") + "\n" +
"🔗 " + .html_url + "\n\n" +
((.body // "No release notes") | split("\n")[0:5] | join("\n"))
' 2>/dev/null)
if [[ -n "$notes" ]]; then
echo ""
echo -e "${CYAN}📋 What's New (${old_version}${new_version}):${NC}"
echo -e "$notes"
echo ""
fi
}
# =============================================================================
# Prerequisite Checks
# =============================================================================
@@ -366,6 +440,10 @@ do_update() {
fatal "Vessel installation not found"
fi
# Capture current version before updating
local old_version
old_version=$(get_installed_version)
info "Pulling latest changes..."
git pull
@@ -375,6 +453,12 @@ do_update() {
success "Vessel has been updated"
wait_for_health
# Get new version and show release notes
local new_version
new_version=$(get_installed_version)
show_release_notes "$old_version" "$new_version"
print_success
exit 0
}