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
This commit is contained in:
2026-01-07 20:03:38 +01:00
parent 949802e935
commit 245526af99
14 changed files with 2852 additions and 601 deletions

View File

@@ -4,15 +4,12 @@
* Contains navigation header, search, projects, and conversation list
*/
import { page } from '$app/stores';
import { uiState, projectsState } from '$lib/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);
@@ -88,100 +85,11 @@
<ConversationList onEditProject={handleEditProject} />
</div>
<!-- Footer / Navigation links -->
<div class="border-t border-theme p-3 space-y-1">
<!-- Model Browser link -->
<a
href="/models"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/models') ? 'bg-cyan-500/20 text-cyan-600 dark:bg-cyan-900/30 dark:text-cyan-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 0-6.23.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
/>
</svg>
<span>Models</span>
</a>
<!-- Knowledge Base link -->
<a
href="/knowledge"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/knowledge') ? 'bg-blue-500/20 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"
/>
</svg>
<span>Knowledge Base</span>
</a>
<!-- Tools link -->
<a
href="/tools"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/tools') ? 'bg-emerald-500/20 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z"
/>
</svg>
<span>Tools</span>
</a>
<!-- Prompts link -->
<a
href="/prompts"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/prompts') ? 'bg-purple-500/20 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
<span>Prompts</span>
</a>
<!-- Settings 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"

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

View File

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

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

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

@@ -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,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
redirect(301, '/settings?tab=prompts');
};

View File

@@ -1,516 +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';
import { EMBEDDING_MODELS } from '$lib/memory/embeddings';
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> {
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);
}
// 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">
<!-- 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>
<p class="mt-2 text-xs text-theme-muted">
Note: The model must be installed in Ollama. Run <code class="bg-theme-tertiary px-1 rounded">ollama pull {settingsState.embeddingModel}</code> if not installed.
</p>
</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>
<!-- 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');
};