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:
@@ -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"
|
||||
|
||||
155
frontend/src/lib/components/settings/GeneralTab.svelte
Normal file
155
frontend/src/lib/components/settings/GeneralTab.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* GeneralTab - General settings including appearance, defaults, shortcuts, and about
|
||||
*/
|
||||
import { modelsState, uiState } from '$lib/stores';
|
||||
import { getPrimaryModifierDisplay } from '$lib/utils';
|
||||
|
||||
const modifierKey = getPrimaryModifierDisplay();
|
||||
|
||||
// Local state for default model selection
|
||||
let defaultModel = $state<string | null>(modelsState.selectedId);
|
||||
|
||||
// Save default model when it changes
|
||||
function handleModelChange(): void {
|
||||
if (defaultModel) {
|
||||
modelsState.select(defaultModel);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Appearance Section -->
|
||||
<section>
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
Appearance
|
||||
</h2>
|
||||
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
|
||||
<!-- Dark Mode Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme-secondary">Dark Mode</p>
|
||||
<p class="text-xs text-theme-muted">Toggle between light and dark theme</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => uiState.toggleDarkMode()}
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-theme {uiState.darkMode ? 'bg-purple-600' : 'bg-theme-tertiary'}"
|
||||
role="switch"
|
||||
aria-checked={uiState.darkMode}
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {uiState.darkMode ? 'translate-x-5' : 'translate-x-0'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- System Theme Sync -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme-secondary">Use System Theme</p>
|
||||
<p class="text-xs text-theme-muted">Match your OS light/dark preference</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => uiState.useSystemTheme()}
|
||||
class="rounded-lg bg-theme-tertiary px-3 py-1.5 text-xs font-medium text-theme-secondary transition-colors hover:bg-theme-hover"
|
||||
>
|
||||
Sync with System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chat Defaults Section -->
|
||||
<section>
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
Chat Defaults
|
||||
</h2>
|
||||
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div>
|
||||
<label for="default-model" class="text-sm font-medium text-theme-secondary">Default Model</label>
|
||||
<p class="text-xs text-theme-muted mb-2">Model used for new conversations</p>
|
||||
<select
|
||||
id="default-model"
|
||||
bind:value={defaultModel}
|
||||
onchange={handleModelChange}
|
||||
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-secondary focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
>
|
||||
{#each modelsState.chatModels as model}
|
||||
<option value={model.name}>{model.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Keyboard Shortcuts Section -->
|
||||
<section>
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-theme-secondary">New Chat</span>
|
||||
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+N</kbd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-theme-secondary">Search</span>
|
||||
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+K</kbd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-theme-secondary">Toggle Sidebar</span>
|
||||
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+B</kbd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-theme-secondary">Send Message</span>
|
||||
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Enter</kbd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-theme-secondary">New Line</span>
|
||||
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Shift+Enter</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About Section -->
|
||||
<section>
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
About
|
||||
</h2>
|
||||
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="rounded-lg bg-theme-tertiary p-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-theme-primary">Vessel</h3>
|
||||
<p class="text-sm text-theme-muted">
|
||||
A modern interface for local AI with chat, tools, and memory management.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
266
frontend/src/lib/components/settings/KnowledgeTab.svelte
Normal file
266
frontend/src/lib/components/settings/KnowledgeTab.svelte
Normal 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>
|
||||
357
frontend/src/lib/components/settings/MemoryTab.svelte
Normal file
357
frontend/src/lib/components/settings/MemoryTab.svelte
Normal 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>
|
||||
966
frontend/src/lib/components/settings/ModelsTab.svelte
Normal file
966
frontend/src/lib/components/settings/ModelsTab.svelte
Normal file
@@ -0,0 +1,966 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ModelsTab - Model browser and management
|
||||
* Browse and search models from ollama.com, manage local models
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { modelRegistry } from '$lib/stores/model-registry.svelte';
|
||||
import { localModelsState } from '$lib/stores/local-models.svelte';
|
||||
import { modelsState } from '$lib/stores/models.svelte';
|
||||
import { modelOperationsState } from '$lib/stores/model-operations.svelte';
|
||||
import { ModelCard } from '$lib/components/models';
|
||||
import PullModelDialog from '$lib/components/models/PullModelDialog.svelte';
|
||||
import ModelEditorDialog from '$lib/components/models/ModelEditorDialog.svelte';
|
||||
import { fetchTagSizes, type RemoteModel } from '$lib/api/model-registry';
|
||||
import { modelInfoService, type ModelInfo } from '$lib/services/model-info-service';
|
||||
import type { ModelEditorMode } from '$lib/stores/model-creation.svelte';
|
||||
|
||||
// Search debounce
|
||||
let searchInput = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleSearchInput(e: Event): void {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
searchInput = value;
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
modelRegistry.search(value);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleTypeFilter(type: 'official' | 'community' | ''): void {
|
||||
modelRegistry.filterByType(type);
|
||||
}
|
||||
|
||||
// Selected model for details panel
|
||||
let selectedModel = $state<RemoteModel | null>(null);
|
||||
let selectedTag = $state<string>('');
|
||||
let pulling = $state(false);
|
||||
let pullProgress = $state<{ status: string; completed?: number; total?: number } | null>(null);
|
||||
let pullError = $state<string | null>(null);
|
||||
let loadingSizes = $state(false);
|
||||
let capabilitiesVerified = $state(false);
|
||||
|
||||
async function handleSelectModel(model: RemoteModel): Promise<void> {
|
||||
selectedModel = model;
|
||||
selectedTag = model.tags[0] || '';
|
||||
pullProgress = null;
|
||||
pullError = null;
|
||||
capabilitiesVerified = false;
|
||||
|
||||
if (!model.tagSizes || Object.keys(model.tagSizes).length === 0) {
|
||||
loadingSizes = true;
|
||||
try {
|
||||
const updatedModel = await fetchTagSizes(model.slug);
|
||||
selectedModel = { ...model, tagSizes: updatedModel.tagSizes };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tag sizes:', err);
|
||||
} finally {
|
||||
loadingSizes = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const realCapabilities = await modelsState.fetchCapabilities(model.slug);
|
||||
if (modelsState.hasCapability(model.slug, 'completion') || realCapabilities.length > 0) {
|
||||
selectedModel = { ...selectedModel!, capabilities: realCapabilities };
|
||||
capabilitiesVerified = true;
|
||||
}
|
||||
} catch {
|
||||
capabilitiesVerified = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetails(): void {
|
||||
selectedModel = null;
|
||||
selectedTag = '';
|
||||
pullProgress = null;
|
||||
pullError = null;
|
||||
}
|
||||
|
||||
async function pullModel(): Promise<void> {
|
||||
if (!selectedModel || pulling) return;
|
||||
|
||||
const modelName = selectedTag
|
||||
? `${selectedModel.slug}:${selectedTag}`
|
||||
: selectedModel.slug;
|
||||
|
||||
pulling = true;
|
||||
pullError = null;
|
||||
pullProgress = { status: 'Starting pull...' };
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/ollama/api/pull', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: modelName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to pull model: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.error) {
|
||||
pullError = data.error;
|
||||
break;
|
||||
}
|
||||
pullProgress = {
|
||||
status: data.status || 'Pulling...',
|
||||
completed: data.completed,
|
||||
total: data.total
|
||||
};
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pullError) {
|
||||
pullProgress = { status: 'Pull complete!' };
|
||||
await modelsState.refresh();
|
||||
modelsState.select(modelName);
|
||||
}
|
||||
} catch (err) {
|
||||
pullError = err instanceof Error ? err.message : 'Failed to pull model';
|
||||
} finally {
|
||||
pulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
return `${value.toFixed(i > 1 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
let deleteConfirm = $state<string | null>(null);
|
||||
let deleting = $state(false);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
let modelEditorOpen = $state(false);
|
||||
let modelEditorMode = $state<ModelEditorMode>('create');
|
||||
let editingModelName = $state<string | undefined>(undefined);
|
||||
let editingSystemPrompt = $state<string | undefined>(undefined);
|
||||
let editingBaseModel = $state<string | undefined>(undefined);
|
||||
|
||||
let modelInfoCache = $state<Map<string, ModelInfo>>(new Map());
|
||||
|
||||
function openCreateDialog(): void {
|
||||
modelEditorMode = 'create';
|
||||
editingModelName = undefined;
|
||||
editingSystemPrompt = undefined;
|
||||
editingBaseModel = undefined;
|
||||
modelEditorOpen = true;
|
||||
}
|
||||
|
||||
async function openEditDialog(modelName: string): Promise<void> {
|
||||
const info = await modelInfoService.getModelInfo(modelName);
|
||||
if (!info.systemPrompt) return;
|
||||
|
||||
const localModel = localModelsState.models.find((m) => m.name === modelName);
|
||||
const baseModel = localModel?.family || modelName;
|
||||
|
||||
modelEditorMode = 'edit';
|
||||
editingModelName = modelName;
|
||||
editingSystemPrompt = info.systemPrompt;
|
||||
editingBaseModel = baseModel;
|
||||
modelEditorOpen = true;
|
||||
}
|
||||
|
||||
function closeModelEditor(): void {
|
||||
modelEditorOpen = false;
|
||||
localModelsState.refresh();
|
||||
}
|
||||
|
||||
async function fetchModelInfoForLocalModels(): Promise<void> {
|
||||
const newCache = new Map<string, ModelInfo>();
|
||||
for (const model of localModelsState.models) {
|
||||
try {
|
||||
const info = await modelInfoService.getModelInfo(model.name);
|
||||
newCache.set(model.name, info);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
modelInfoCache = newCache;
|
||||
}
|
||||
|
||||
function hasEmbeddedPrompt(modelName: string): boolean {
|
||||
const info = modelInfoCache.get(modelName);
|
||||
return info?.systemPrompt !== null && info?.systemPrompt !== undefined && info.systemPrompt.length > 0;
|
||||
}
|
||||
|
||||
async function deleteModel(modelName: string): Promise<void> {
|
||||
if (deleting) return;
|
||||
|
||||
deleting = true;
|
||||
deleteError = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/ollama/api/delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: modelName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || `Failed to delete: ${response.statusText}`);
|
||||
}
|
||||
|
||||
await localModelsState.refresh();
|
||||
await modelsState.refresh();
|
||||
deleteConfirm = null;
|
||||
} catch (err) {
|
||||
deleteError = err instanceof Error ? err.message : 'Failed to delete model';
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
let activeTab = $state<'local' | 'browse'>('local');
|
||||
|
||||
let localSearchInput = $state('');
|
||||
let localSearchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleLocalSearchInput(e: Event): void {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
localSearchInput = value;
|
||||
|
||||
if (localSearchTimeout) clearTimeout(localSearchTimeout);
|
||||
localSearchTimeout = setTimeout(() => {
|
||||
localModelsState.search(value);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (localModelsState.models.length > 0) {
|
||||
fetchModelInfoForLocalModels();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
localModelsState.init();
|
||||
modelRegistry.init();
|
||||
modelsState.refresh().then(() => {
|
||||
modelsState.fetchAllCapabilities();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full overflow-hidden">
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-theme-primary">Models</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Manage local models and browse ollama.com
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
{#if activeTab === 'browse' && modelRegistry.syncStatus}
|
||||
<div class="text-right text-xs text-theme-muted">
|
||||
<div>{modelRegistry.syncStatus.modelCount} models cached</div>
|
||||
<div>Last sync: {formatDate(modelRegistry.syncStatus.lastSync ?? undefined)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if activeTab === 'browse'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.sync()}
|
||||
disabled={modelRegistry.syncing}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if modelRegistry.syncing}
|
||||
<svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Syncing...</span>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Sync Models</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateDialog}
|
||||
class="flex items-center gap-2 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-violet-500"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>Create Custom</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelOperationsState.openPullDialog()}
|
||||
class="flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-sky-500"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Pull Model</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => localModelsState.checkUpdates()}
|
||||
disabled={localModelsState.isCheckingUpdates}
|
||||
class="flex items-center gap-2 rounded-lg border border-amber-700 bg-amber-900/20 px-4 py-2 text-sm font-medium text-amber-300 transition-colors hover:bg-amber-900/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if localModelsState.isCheckingUpdates}
|
||||
<svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Checking...</span>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<span>Check Updates</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => localModelsState.refresh()}
|
||||
disabled={localModelsState.loading}
|
||||
class="flex items-center gap-2 rounded-lg border border-theme bg-theme-secondary px-4 py-2 text-sm font-medium text-theme-secondary transition-colors hover:bg-theme-tertiary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if localModelsState.loading}
|
||||
<svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6 flex border-b border-theme">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => activeTab = 'local'}
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'local'
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
Local Models
|
||||
<span class="rounded-full bg-theme-tertiary px-2 py-0.5 text-xs">{localModelsState.total}</span>
|
||||
{#if localModelsState.updatesAvailable > 0}
|
||||
<span class="rounded-full bg-amber-600 px-2 py-0.5 text-xs text-theme-primary" title="{localModelsState.updatesAvailable} update{localModelsState.updatesAvailable !== 1 ? 's' : ''} available">
|
||||
{localModelsState.updatesAvailable}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => activeTab = 'browse'}
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'browse'
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
Browse ollama.com
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Local Models Tab -->
|
||||
{#if activeTab === 'local'}
|
||||
{#if deleteError}
|
||||
<div class="mb-4 rounded-lg border border-red-900/50 bg-red-900/20 p-4">
|
||||
<div class="flex items-center gap-2 text-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{deleteError}</span>
|
||||
<button type="button" onclick={() => deleteError = null} class="ml-auto text-red-400 hover:text-red-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Local Models Search/Filter Bar -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-4">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={localSearchInput}
|
||||
oninput={handleLocalSearchInput}
|
||||
placeholder="Search local models..."
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-theme-primary placeholder-theme-placeholder focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if localModelsState.families.length > 0}
|
||||
<select
|
||||
value={localModelsState.familyFilter}
|
||||
onchange={(e) => localModelsState.filterByFamily((e.target as HTMLSelectElement).value)}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Families</option>
|
||||
{#each localModelsState.families as family}
|
||||
<option value={family}>{family}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<select
|
||||
value={localModelsState.sortBy}
|
||||
onchange={(e) => localModelsState.setSort((e.target as HTMLSelectElement).value as import('$lib/api/model-registry').LocalModelSortOption)}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="name_asc">Name A-Z</option>
|
||||
<option value="name_desc">Name Z-A</option>
|
||||
<option value="size_desc">Largest</option>
|
||||
<option value="size_asc">Smallest</option>
|
||||
<option value="modified_desc">Recently Modified</option>
|
||||
<option value="modified_asc">Oldest Modified</option>
|
||||
</select>
|
||||
|
||||
{#if localModelsState.searchQuery || localModelsState.familyFilter || localModelsState.sortBy !== 'name_asc'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { localModelsState.clearFilters(); localSearchInput = ''; }}
|
||||
class="text-sm text-theme-muted hover:text-theme-primary"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if localModelsState.loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="animate-pulse rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="h-5 w-48 rounded bg-theme-tertiary"></div>
|
||||
<div class="h-5 w-20 rounded bg-theme-tertiary"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if localModelsState.models.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-12 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-theme-muted">
|
||||
{#if localModelsState.searchQuery || localModelsState.familyFilter}
|
||||
No models match your filters
|
||||
{:else}
|
||||
No local models
|
||||
{/if}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
{#if localModelsState.searchQuery || localModelsState.familyFilter}
|
||||
Try adjusting your search or filters
|
||||
{:else}
|
||||
Browse ollama.com to pull models
|
||||
{/if}
|
||||
</p>
|
||||
{#if !localModelsState.searchQuery && !localModelsState.familyFilter}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => activeTab = 'browse'}
|
||||
class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700"
|
||||
>
|
||||
Browse Models
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each localModelsState.models as model (model.name)}
|
||||
{@const caps = modelsState.getCapabilities(model.name) ?? []}
|
||||
<div class="group rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:border-theme-subtle">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="font-medium text-theme-primary">{model.name}</h3>
|
||||
{#if model.name === modelsState.selectedId}
|
||||
<span class="rounded bg-blue-900/50 px-2 py-0.5 text-xs text-blue-300">Selected</span>
|
||||
{/if}
|
||||
{#if localModelsState.hasUpdate(model.name)}
|
||||
<span class="rounded bg-amber-600 px-2 py-0.5 text-xs font-medium text-theme-primary" title="Update available">
|
||||
Update
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasEmbeddedPrompt(model.name)}
|
||||
<span class="rounded bg-violet-900/50 px-2 py-0.5 text-xs text-violet-300" title="Custom model with embedded system prompt">
|
||||
Custom
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-4 text-xs text-theme-muted">
|
||||
<span>{formatBytes(model.size)}</span>
|
||||
<span>Family: {model.family}</span>
|
||||
<span>Parameters: {model.parameterSize}</span>
|
||||
<span>Quantization: {model.quantizationLevel}</span>
|
||||
</div>
|
||||
{#if caps.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#if caps.includes('vision')}
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-purple-900/50 text-purple-300">
|
||||
<span>👁</span><span>Vision</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if caps.includes('tools')}
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-blue-900/50 text-blue-300">
|
||||
<span>🔧</span><span>Tools</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if caps.includes('thinking')}
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-pink-900/50 text-pink-300">
|
||||
<span>🧠</span><span>Thinking</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if caps.includes('embedding')}
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-amber-900/50 text-amber-300">
|
||||
<span>📊</span><span>Embedding</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if caps.includes('code')}
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-emerald-900/50 text-emerald-300">
|
||||
<span>💻</span><span>Code</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if deleteConfirm === model.name}
|
||||
<span class="text-sm text-theme-muted">Delete?</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteModel(model.name)}
|
||||
disabled={deleting}
|
||||
class="rounded bg-red-600 px-3 py-1 text-sm font-medium text-theme-primary hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteConfirm = null}
|
||||
disabled={deleting}
|
||||
class="rounded bg-theme-tertiary px-3 py-1 text-sm font-medium text-theme-secondary hover:bg-theme-secondary disabled:opacity-50"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
{:else}
|
||||
{#if hasEmbeddedPrompt(model.name)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditDialog(model.name)}
|
||||
class="rounded p-2 text-theme-muted opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-violet-400 group-hover:opacity-100"
|
||||
title="Edit system prompt"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteConfirm = model.name}
|
||||
class="rounded p-2 text-theme-muted opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-red-400 group-hover:opacity-100"
|
||||
title="Delete model"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if localModelsState.totalPages > 1}
|
||||
<div class="mt-6 flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => localModelsState.prevPage()}
|
||||
disabled={!localModelsState.hasPrevPage}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary hover:bg-theme-tertiary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span class="px-3 text-sm text-theme-muted">
|
||||
Page {localModelsState.currentPage + 1} of {localModelsState.totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => localModelsState.nextPage()}
|
||||
disabled={!localModelsState.hasNextPage}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary hover:bg-theme-tertiary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Browse Tab - Search and Filters -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-4">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
oninput={handleSearchInput}
|
||||
placeholder="Search models..."
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-theme-primary placeholder-theme-placeholder focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex rounded-lg border border-theme bg-theme-secondary p-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTypeFilter('')}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === ''
|
||||
? 'bg-theme-tertiary text-theme-primary'
|
||||
: 'text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTypeFilter('official')}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === 'official'
|
||||
? 'bg-blue-600 text-theme-primary'
|
||||
: 'text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
Official
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTypeFilter('community')}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === 'community'
|
||||
? 'bg-theme-tertiary text-theme-primary'
|
||||
: 'text-theme-muted hover:text-theme-primary'}"
|
||||
>
|
||||
Community
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="sort-select" class="text-sm text-theme-muted">Sort:</label>
|
||||
<select
|
||||
id="sort-select"
|
||||
value={modelRegistry.sortBy}
|
||||
onchange={(e) => modelRegistry.setSort((e.target as HTMLSelectElement).value as import('$lib/api/model-registry').ModelSortOption)}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-1.5 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="pulls_desc">Most Popular</option>
|
||||
<option value="pulls_asc">Least Popular</option>
|
||||
<option value="name_asc">Name A-Z</option>
|
||||
<option value="name_desc">Name Z-A</option>
|
||||
<option value="updated_desc">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-theme-muted">
|
||||
{modelRegistry.total} model{modelRegistry.total !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capability Filters -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-theme-muted">Capabilities:</span>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('vision')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('vision') ? 'bg-purple-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>👁</span><span>Vision</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('tools')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('tools') ? 'bg-blue-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>🔧</span><span>Tools</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('thinking')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('thinking') ? 'bg-pink-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>🧠</span><span>Thinking</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('embedding')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('embedding') ? 'bg-amber-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>📊</span><span>Embedding</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleCapability('cloud')} class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('cloud') ? 'bg-cyan-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">
|
||||
<span>☁️</span><span>Cloud</span>
|
||||
</button>
|
||||
<span class="ml-2 text-xs text-theme-muted opacity-60">from ollama.com</span>
|
||||
</div>
|
||||
|
||||
<!-- Size Range Filters -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-theme-muted">Size:</span>
|
||||
<button type="button" onclick={() => modelRegistry.toggleSizeRange('small')} class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('small') ? 'bg-emerald-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">≤3B</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleSizeRange('medium')} class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('medium') ? 'bg-emerald-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">4-13B</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleSizeRange('large')} class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('large') ? 'bg-emerald-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">14-70B</button>
|
||||
<button type="button" onclick={() => modelRegistry.toggleSizeRange('xlarge')} class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('xlarge') ? 'bg-emerald-600 text-theme-primary' : 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}">>70B</button>
|
||||
</div>
|
||||
|
||||
<!-- Family Filter + Clear -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-4">
|
||||
{#if modelRegistry.availableFamilies.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-theme-muted">Family:</span>
|
||||
<select
|
||||
value={modelRegistry.selectedFamily}
|
||||
onchange={(e) => modelRegistry.setFamily((e.target as HTMLSelectElement).value)}
|
||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-1.5 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Families</option>
|
||||
{#each modelRegistry.availableFamilies as family}
|
||||
<option value={family}>{family}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.selectedSizeRanges.length > 0 || modelRegistry.selectedFamily || modelRegistry.modelType || modelRegistry.searchQuery || modelRegistry.sortBy !== 'pulls_desc'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { modelRegistry.clearFilters(); searchInput = ''; }}
|
||||
class="text-sm text-theme-muted hover:text-theme-primary"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if modelRegistry.error}
|
||||
<div class="mb-6 rounded-lg border border-red-900/50 bg-red-900/20 p-4">
|
||||
<div class="flex items-center gap-2 text-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{modelRegistry.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if modelRegistry.loading}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="h-5 w-32 rounded bg-theme-tertiary"></div>
|
||||
<div class="h-5 w-16 rounded bg-theme-tertiary"></div>
|
||||
</div>
|
||||
<div class="mt-3 h-4 w-full rounded bg-theme-tertiary"></div>
|
||||
<div class="mt-2 h-4 w-2/3 rounded bg-theme-tertiary"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if modelRegistry.models.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-12 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611l-.628.105a9.002 9.002 0 01-9.014 0l-.628-.105c-1.717-.293-2.3-2.379-1.067-3.61L5 14.5" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-theme-muted">No models found</h3>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
{#if modelRegistry.searchQuery || modelRegistry.modelType}
|
||||
Try adjusting your search or filters
|
||||
{:else}
|
||||
Click "Sync Models" to fetch models from ollama.com
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each modelRegistry.models as model (model.slug)}
|
||||
<ModelCard {model} onSelect={handleSelectModel} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if modelRegistry.totalPages > 1}
|
||||
<div class="mt-6 flex items-center justify-center gap-2">
|
||||
<button type="button" onclick={() => modelRegistry.prevPage()} disabled={!modelRegistry.hasPrevPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-sm text-theme-muted">Page {modelRegistry.currentPage + 1} of {modelRegistry.totalPages}</span>
|
||||
<button type="button" onclick={() => modelRegistry.nextPage()} disabled={!modelRegistry.hasNextPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Model Details Sidebar -->
|
||||
{#if selectedModel}
|
||||
<div class="w-80 flex-shrink-0 overflow-y-auto border-l border-theme bg-theme-secondary p-4">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<h3 class="text-lg font-semibold text-theme-primary">{selectedModel.name}</h3>
|
||||
<button type="button" onclick={closeDetails} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<span class="rounded px-2 py-1 text-xs {selectedModel.modelType === 'official' ? 'bg-blue-900/50 text-blue-300' : 'bg-theme-tertiary text-theme-muted'}">
|
||||
{selectedModel.modelType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if selectedModel.description}
|
||||
<div class="mb-4">
|
||||
<h4 class="mb-2 text-sm font-medium text-theme-secondary">Description</h4>
|
||||
<p class="text-sm text-theme-muted">{selectedModel.description}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedModel.capabilities.length > 0}
|
||||
<div class="mb-4">
|
||||
<h4 class="mb-2 flex items-center gap-2 text-sm font-medium text-theme-secondary">
|
||||
<span>Capabilities</span>
|
||||
{#if capabilitiesVerified}
|
||||
<span class="inline-flex items-center gap-1 rounded bg-green-900/30 px-1.5 py-0.5 text-xs text-green-400">✓ verified</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-1 rounded bg-amber-900/30 px-1.5 py-0.5 text-xs text-amber-400">unverified</span>
|
||||
{/if}
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each selectedModel.capabilities as cap}
|
||||
<span class="rounded bg-theme-tertiary px-2 py-1 text-xs text-theme-secondary">{cap}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pull Section -->
|
||||
<div class="mb-4">
|
||||
<h4 class="mb-2 text-sm font-medium text-theme-secondary">Pull Model</h4>
|
||||
{#if selectedModel.tags.length > 0}
|
||||
<select bind:value={selectedTag} disabled={pulling} class="mb-2 w-full rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary disabled:opacity-50">
|
||||
{#each selectedModel.tags as tag}
|
||||
{@const size = selectedModel.tagSizes?.[tag]}
|
||||
<option value={tag}>{selectedModel.slug}:{tag} {size ? `(${formatBytes(size)})` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button type="button" onclick={pullModel} disabled={pulling} class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700 disabled:opacity-50">
|
||||
{#if pulling}
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
Pulling...
|
||||
{:else}
|
||||
Pull Model
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if pullProgress}
|
||||
<div class="mt-2 text-xs text-theme-muted">{pullProgress.status}</div>
|
||||
{#if pullProgress.completed !== undefined && pullProgress.total}
|
||||
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-theme-tertiary">
|
||||
<div class="h-full bg-blue-500 transition-all" style="width: {Math.round((pullProgress.completed / pullProgress.total) * 100)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if pullError}
|
||||
<div class="mt-2 rounded border border-red-900/50 bg-red-900/20 p-2 text-xs text-red-400">{pullError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<a href={selectedModel.url} target="_blank" rel="noopener noreferrer" class="flex w-full items-center justify-center gap-2 rounded-lg border border-theme bg-theme-secondary px-4 py-2 text-sm text-theme-secondary hover:bg-theme-tertiary">
|
||||
View on ollama.com
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PullModelDialog />
|
||||
<ModelEditorDialog isOpen={modelEditorOpen} mode={modelEditorMode} editingModel={editingModelName} currentSystemPrompt={editingSystemPrompt} baseModel={editingBaseModel} onClose={closeModelEditor} />
|
||||
|
||||
{#if modelOperationsState.activePulls.size > 0}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-40 border-t border-theme bg-theme-secondary/95 p-4 backdrop-blur-sm">
|
||||
<div class="mx-auto max-w-4xl space-y-3">
|
||||
<h3 class="text-sm font-medium text-theme-secondary">Active Downloads</h3>
|
||||
{#each [...modelOperationsState.activePulls.entries()] as [name, pull]}
|
||||
<div class="rounded-lg bg-theme-primary/50 p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-medium text-theme-secondary">{name}</span>
|
||||
<button type="button" onclick={() => modelOperationsState.cancelPull(name)} class="text-xs text-red-400 hover:text-red-300">Cancel</button>
|
||||
</div>
|
||||
<div class="mb-1 flex items-center gap-3">
|
||||
<div class="h-2 flex-1 overflow-hidden rounded-full bg-theme-tertiary">
|
||||
<div class="h-full bg-sky-500 transition-all" style="width: {pull.progress.percent}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-theme-muted">{pull.progress.percent}%</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-theme-muted">
|
||||
<span>{pull.progress.status}</span>
|
||||
{#if pull.progress.speed}
|
||||
<span>{modelOperationsState.formatBytes(pull.progress.speed)}/s</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
446
frontend/src/lib/components/settings/PromptsTab.svelte
Normal file
446
frontend/src/lib/components/settings/PromptsTab.svelte
Normal file
@@ -0,0 +1,446 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* PromptsTab - System prompts management
|
||||
*/
|
||||
import { promptsState, type Prompt } from '$lib/stores';
|
||||
import {
|
||||
getAllPromptTemplates,
|
||||
getPromptCategories,
|
||||
categoryInfo,
|
||||
type PromptTemplate,
|
||||
type PromptCategory
|
||||
} from '$lib/prompts/templates';
|
||||
|
||||
type Tab = 'my-prompts' | 'browse-templates';
|
||||
let activeTab = $state<Tab>('my-prompts');
|
||||
|
||||
let showEditor = $state(false);
|
||||
let editingPrompt = $state<Prompt | null>(null);
|
||||
|
||||
let formName = $state('');
|
||||
let formDescription = $state('');
|
||||
let formContent = $state('');
|
||||
let formIsDefault = $state(false);
|
||||
let formTargetCapabilities = $state<string[]>([]);
|
||||
let isSaving = $state(false);
|
||||
|
||||
let selectedCategory = $state<PromptCategory | 'all'>('all');
|
||||
let previewTemplate = $state<PromptTemplate | null>(null);
|
||||
let addingTemplateId = $state<string | null>(null);
|
||||
|
||||
const templates = getAllPromptTemplates();
|
||||
const categories = getPromptCategories();
|
||||
|
||||
const filteredTemplates = $derived(
|
||||
selectedCategory === 'all'
|
||||
? templates
|
||||
: templates.filter((t) => t.category === selectedCategory)
|
||||
);
|
||||
|
||||
const CAPABILITIES = [
|
||||
{ id: 'code', label: 'Code', description: 'Auto-use with coding models' },
|
||||
{ id: 'vision', label: 'Vision', description: 'Auto-use with vision models' },
|
||||
{ id: 'thinking', label: 'Thinking', description: 'Auto-use with reasoning models' },
|
||||
{ id: 'tools', label: 'Tools', description: 'Auto-use with tool-capable models' }
|
||||
] as const;
|
||||
|
||||
function openCreateEditor(): void {
|
||||
editingPrompt = null;
|
||||
formName = '';
|
||||
formDescription = '';
|
||||
formContent = '';
|
||||
formIsDefault = false;
|
||||
formTargetCapabilities = [];
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function openEditEditor(prompt: Prompt): void {
|
||||
editingPrompt = prompt;
|
||||
formName = prompt.name;
|
||||
formDescription = prompt.description;
|
||||
formContent = prompt.content;
|
||||
formIsDefault = prompt.isDefault;
|
||||
formTargetCapabilities = prompt.targetCapabilities ?? [];
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function closeEditor(): void {
|
||||
showEditor = false;
|
||||
editingPrompt = null;
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!formName.trim() || !formContent.trim()) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
const capabilities = formTargetCapabilities.length > 0 ? formTargetCapabilities : undefined;
|
||||
if (editingPrompt) {
|
||||
await promptsState.update(editingPrompt.id, {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim(),
|
||||
content: formContent,
|
||||
isDefault: formIsDefault,
|
||||
targetCapabilities: capabilities ?? []
|
||||
});
|
||||
} else {
|
||||
await promptsState.add({
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim(),
|
||||
content: formContent,
|
||||
isDefault: formIsDefault,
|
||||
targetCapabilities: capabilities
|
||||
});
|
||||
}
|
||||
closeEditor();
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCapability(capId: string): void {
|
||||
if (formTargetCapabilities.includes(capId)) {
|
||||
formTargetCapabilities = formTargetCapabilities.filter((c) => c !== capId);
|
||||
} else {
|
||||
formTargetCapabilities = [...formTargetCapabilities, capId];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(prompt: Prompt): Promise<void> {
|
||||
if (confirm(`Delete "${prompt.name}"? This cannot be undone.`)) {
|
||||
await promptsState.remove(prompt.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetDefault(prompt: Prompt): Promise<void> {
|
||||
if (prompt.isDefault) {
|
||||
await promptsState.clearDefault();
|
||||
} else {
|
||||
await promptsState.setDefault(prompt.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetActive(prompt: Prompt): void {
|
||||
if (promptsState.activePromptId === prompt.id) {
|
||||
promptsState.setActive(null);
|
||||
} else {
|
||||
promptsState.setActive(prompt.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function addTemplateToLibrary(template: PromptTemplate): Promise<void> {
|
||||
addingTemplateId = template.id;
|
||||
try {
|
||||
await promptsState.add({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
content: template.content,
|
||||
isDefault: false,
|
||||
targetCapabilities: template.targetCapabilities
|
||||
});
|
||||
activeTab = 'my-prompts';
|
||||
} finally {
|
||||
addingTemplateId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-theme-primary">System Prompts</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Create and manage system prompt templates for conversations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'my-prompts'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Prompt
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6 flex gap-1 rounded-lg bg-theme-tertiary p-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'my-prompts')}
|
||||
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab === 'my-prompts'
|
||||
? 'bg-theme-secondary text-theme-primary shadow'
|
||||
: 'text-theme-muted hover:text-theme-secondary'}"
|
||||
>
|
||||
My Prompts
|
||||
{#if promptsState.prompts.length > 0}
|
||||
<span class="ml-1.5 rounded-full bg-theme-tertiary px-2 py-0.5 text-xs {activeTab === 'my-prompts' ? 'bg-blue-500/20 text-blue-400' : ''}">
|
||||
{promptsState.prompts.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'browse-templates')}
|
||||
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab === 'browse-templates'
|
||||
? 'bg-theme-secondary text-theme-primary shadow'
|
||||
: 'text-theme-muted hover:text-theme-secondary'}"
|
||||
>
|
||||
Browse Templates
|
||||
<span class="ml-1.5 rounded-full bg-theme-tertiary px-2 py-0.5 text-xs {activeTab === 'browse-templates' ? 'bg-purple-500/20 text-purple-400' : ''}">
|
||||
{templates.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- My Prompts Tab -->
|
||||
{#if activeTab === 'my-prompts'}
|
||||
{#if promptsState.activePrompt}
|
||||
<div class="mb-6 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-blue-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Active system prompt: <strong class="text-blue-300">{promptsState.activePrompt.name}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if promptsState.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-theme-subtle border-t-blue-500"></div>
|
||||
</div>
|
||||
{:else if promptsState.prompts.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-theme-muted">No system prompts yet</h3>
|
||||
<p class="mt-1 text-sm text-theme-muted">Create a prompt or browse templates to get started</p>
|
||||
<div class="mt-4 flex justify-center gap-3">
|
||||
<button type="button" onclick={openCreateEditor} class="inline-flex items-center gap-2 rounded-lg bg-theme-tertiary px-4 py-2 text-sm font-medium text-theme-primary hover:bg-theme-tertiary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create from scratch
|
||||
</button>
|
||||
<button type="button" onclick={() => (activeTab = 'browse-templates')} class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-purple-700">
|
||||
Browse templates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each promptsState.prompts as prompt (prompt.id)}
|
||||
<div class="rounded-lg border bg-theme-secondary p-4 transition-colors {promptsState.activePromptId === prompt.id ? 'border-blue-500/50' : 'border-theme'}">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-medium text-theme-primary">{prompt.name}</h3>
|
||||
{#if prompt.isDefault}
|
||||
<span class="rounded bg-blue-900 px-2 py-0.5 text-xs text-blue-300">default</span>
|
||||
{/if}
|
||||
{#if promptsState.activePromptId === prompt.id}
|
||||
<span class="rounded bg-emerald-900 px-2 py-0.5 text-xs text-emerald-300">active</span>
|
||||
{/if}
|
||||
{#if prompt.targetCapabilities && prompt.targetCapabilities.length > 0}
|
||||
{#each prompt.targetCapabilities as cap (cap)}
|
||||
<span class="rounded bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">{cap}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if prompt.description}
|
||||
<p class="mt-1 text-sm text-theme-muted">{prompt.description}</p>
|
||||
{/if}
|
||||
<p class="mt-2 line-clamp-2 text-sm text-theme-muted">{prompt.content}</p>
|
||||
<p class="mt-2 text-xs text-theme-muted">Updated {formatDate(prompt.updatedAt)}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick={() => handleSetActive(prompt)} class="rounded p-1.5 transition-colors {promptsState.activePromptId === prompt.id ? 'bg-emerald-600 text-theme-primary' : 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}" title={promptsState.activePromptId === prompt.id ? 'Deactivate' : 'Use for new chats'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" onclick={() => handleSetDefault(prompt)} class="rounded p-1.5 transition-colors {prompt.isDefault ? 'bg-blue-600 text-theme-primary' : 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}" title={prompt.isDefault ? 'Remove as default' : 'Set as default'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill={prompt.isDefault ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" onclick={() => openEditEditor(prompt)} class="rounded p-1.5 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary" title="Edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" onclick={() => handleDelete(prompt)} class="rounded p-1.5 text-theme-muted hover:bg-red-900/30 hover:text-red-400" title="Delete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Browse Templates Tab -->
|
||||
{#if activeTab === 'browse-templates'}
|
||||
<div class="mb-6 flex flex-wrap gap-2">
|
||||
<button type="button" onclick={() => (selectedCategory = 'all')} class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory === 'all' ? 'bg-theme-secondary text-theme-primary' : 'bg-theme-tertiary text-theme-muted hover:text-theme-secondary'}">
|
||||
All
|
||||
</button>
|
||||
{#each categories as category (category)}
|
||||
{@const info = categoryInfo[category]}
|
||||
<button type="button" onclick={() => (selectedCategory = category)} class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory === category ? info.color : 'bg-theme-tertiary text-theme-muted hover:text-theme-secondary'}">
|
||||
<span>{info.icon}</span>
|
||||
{info.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each filteredTemplates as template (template.id)}
|
||||
{@const info = categoryInfo[template.category]}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-medium text-theme-primary">{template.name}</h3>
|
||||
<span class="mt-1 inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
|
||||
<span>{info.icon}</span>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onclick={() => addTemplateToLibrary(template)} disabled={addingTemplateId === template.id} class="flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-theme-primary hover:bg-blue-700 disabled:opacity-50">
|
||||
{#if addingTemplateId === template.id}
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{/if}
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-theme-muted">{template.description}</p>
|
||||
<button type="button" onclick={() => (previewTemplate = template)} class="mt-3 text-sm text-blue-400 hover:text-blue-300">
|
||||
Preview prompt
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Editor Modal -->
|
||||
{#if showEditor}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) closeEditor(); }} role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-2xl rounded-xl bg-theme-secondary shadow-xl">
|
||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||
<h3 class="text-lg font-semibold text-theme-primary">{editingPrompt ? 'Edit Prompt' : 'Create Prompt'}</h3>
|
||||
<button type="button" onclick={closeEditor} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }} class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="prompt-name" class="mb-1 block text-sm font-medium text-theme-secondary">Name <span class="text-red-400">*</span></label>
|
||||
<input id="prompt-name" type="text" bind:value={formName} placeholder="e.g., Code Reviewer" class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="prompt-description" class="mb-1 block text-sm font-medium text-theme-secondary">Description</label>
|
||||
<input id="prompt-description" type="text" bind:value={formDescription} placeholder="Brief description" class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="prompt-content" class="mb-1 block text-sm font-medium text-theme-secondary">System Prompt <span class="text-red-400">*</span></label>
|
||||
<textarea id="prompt-content" bind:value={formContent} placeholder="You are a helpful assistant that..." rows="8" class="w-full resize-none rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 font-mono text-sm text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" required></textarea>
|
||||
<p class="mt-1 text-xs text-theme-muted">{formContent.length} characters</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="prompt-default" type="checkbox" bind:checked={formIsDefault} class="h-4 w-4 rounded border-theme-subtle bg-theme-tertiary text-blue-600 focus:ring-blue-500 focus:ring-offset-theme" />
|
||||
<label for="prompt-default" class="text-sm text-theme-secondary">Set as default for new chats</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-theme-secondary">Auto-use for model types</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each CAPABILITIES as cap (cap.id)}
|
||||
<button type="button" onclick={() => toggleCapability(cap.id)} class="rounded-lg border px-3 py-1.5 text-sm transition-colors {formTargetCapabilities.includes(cap.id) ? 'border-blue-500 bg-blue-500/20 text-blue-300' : 'border-theme-subtle bg-theme-tertiary text-theme-muted hover:border-theme hover:text-theme-secondary'}" title={cap.description}>
|
||||
{cap.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button type="button" onclick={closeEditor} class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary hover:bg-theme-tertiary">Cancel</button>
|
||||
<button type="submit" disabled={isSaving || !formName.trim() || !formContent.trim()} class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{isSaving ? 'Saving...' : editingPrompt ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Template Preview Modal -->
|
||||
{#if previewTemplate}
|
||||
{@const info = categoryInfo[previewTemplate.category]}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) previewTemplate = null; }} role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-xl bg-theme-secondary shadow-xl">
|
||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-theme-primary">{previewTemplate.name}</h3>
|
||||
<span class="mt-1 inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
|
||||
<span>{info.icon}</span>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onclick={() => (previewTemplate = null)} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<p class="mb-4 text-sm text-theme-muted">{previewTemplate.description}</p>
|
||||
<pre class="whitespace-pre-wrap rounded-lg bg-theme-tertiary p-4 font-mono text-sm text-theme-primary">{previewTemplate.content}</pre>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
|
||||
<button type="button" onclick={() => (previewTemplate = null)} class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary hover:bg-theme-tertiary">Close</button>
|
||||
<button type="button" onclick={() => { if (previewTemplate) { addTemplateToLibrary(previewTemplate); previewTemplate = null; } }} class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary hover:bg-blue-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add to Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
70
frontend/src/lib/components/settings/SettingsTabs.svelte
Normal file
70
frontend/src/lib/components/settings/SettingsTabs.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" module>
|
||||
/**
|
||||
* SettingsTabs - Horizontal tab navigation for Settings Hub
|
||||
*/
|
||||
export type SettingsTab = 'general' | 'models' | 'prompts' | 'tools' | 'knowledge' | 'memory';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Tab {
|
||||
id: SettingsTab;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'models', label: 'Models', icon: 'cpu' },
|
||||
{ id: 'prompts', label: 'Prompts', icon: 'message' },
|
||||
{ id: 'tools', label: 'Tools', icon: 'wrench' },
|
||||
{ id: 'knowledge', label: 'Knowledge', icon: 'book' },
|
||||
{ id: 'memory', label: 'Memory', icon: 'brain' }
|
||||
];
|
||||
|
||||
// Get active tab from URL, default to 'general'
|
||||
let activeTab = $derived<SettingsTab>(
|
||||
($page.url.searchParams.get('tab') as SettingsTab) || 'general'
|
||||
);
|
||||
</script>
|
||||
|
||||
<nav class="flex gap-1 overflow-x-auto">
|
||||
{#each tabs as tab}
|
||||
<a
|
||||
href="/settings?tab={tab.id}"
|
||||
class="flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium transition-colors
|
||||
{activeTab === tab.id
|
||||
? 'border-violet-500 text-violet-400'
|
||||
: 'border-transparent text-theme-muted hover:border-theme hover:text-theme-primary'}"
|
||||
>
|
||||
{#if tab.icon === 'settings'}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.559.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.272-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'cpu'}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'message'}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'wrench'}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'book'}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'brain'}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
511
frontend/src/lib/components/settings/ToolsTab.svelte
Normal file
511
frontend/src/lib/components/settings/ToolsTab.svelte
Normal file
@@ -0,0 +1,511 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ToolsTab - Enhanced tools management with better visuals
|
||||
*/
|
||||
import { toolsState } from '$lib/stores';
|
||||
import type { ToolDefinition, CustomTool } from '$lib/tools';
|
||||
import { ToolEditor } from '$lib/components/tools';
|
||||
|
||||
let showEditor = $state(false);
|
||||
let editingTool = $state<CustomTool | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let expandedDescriptions = $state<Set<string>>(new Set());
|
||||
|
||||
function openCreateEditor(): void {
|
||||
editingTool = null;
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function openEditEditor(tool: CustomTool): void {
|
||||
editingTool = tool;
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function handleSaveTool(tool: CustomTool): void {
|
||||
if (editingTool) {
|
||||
toolsState.updateCustomTool(tool.id, tool);
|
||||
} else {
|
||||
toolsState.addCustomTool(tool);
|
||||
}
|
||||
showEditor = false;
|
||||
editingTool = null;
|
||||
}
|
||||
|
||||
function handleDeleteTool(tool: CustomTool): void {
|
||||
if (confirm(`Delete "${tool.name}"? This cannot be undone.`)) {
|
||||
toolsState.removeCustomTool(tool.id);
|
||||
}
|
||||
}
|
||||
|
||||
const allTools = $derived(toolsState.getAllToolsWithState());
|
||||
const builtinTools = $derived(allTools.filter(t => t.isBuiltin));
|
||||
|
||||
// Stats
|
||||
const stats = $derived({
|
||||
total: builtinTools.length + toolsState.customTools.length,
|
||||
enabled: builtinTools.filter(t => t.enabled).length + toolsState.customTools.filter(t => t.enabled).length,
|
||||
builtin: builtinTools.length,
|
||||
custom: toolsState.customTools.length
|
||||
});
|
||||
|
||||
// Filtered tools based on search
|
||||
const filteredBuiltinTools = $derived(
|
||||
searchQuery.trim()
|
||||
? builtinTools.filter(t =>
|
||||
t.definition.function.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.definition.function.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: builtinTools
|
||||
);
|
||||
|
||||
const filteredCustomTools = $derived(
|
||||
searchQuery.trim()
|
||||
? toolsState.customTools.filter(t =>
|
||||
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: toolsState.customTools
|
||||
);
|
||||
|
||||
function toggleTool(name: string): void {
|
||||
toolsState.toggleTool(name);
|
||||
}
|
||||
|
||||
function toggleGlobalTools(): void {
|
||||
toolsState.toggleToolsEnabled();
|
||||
}
|
||||
|
||||
function toggleDescription(toolName: string): void {
|
||||
const newSet = new Set(expandedDescriptions);
|
||||
if (newSet.has(toolName)) {
|
||||
newSet.delete(toolName);
|
||||
} else {
|
||||
newSet.add(toolName);
|
||||
}
|
||||
expandedDescriptions = newSet;
|
||||
}
|
||||
|
||||
// Get icon for built-in tool based on name
|
||||
function getToolIcon(name: string): { icon: string; color: string } {
|
||||
const icons: Record<string, { icon: string; color: string }> = {
|
||||
'get_current_time': { icon: 'clock', color: 'text-amber-400' },
|
||||
'calculate': { icon: 'calculator', color: 'text-blue-400' },
|
||||
'fetch_url': { icon: 'globe', color: 'text-cyan-400' },
|
||||
'get_location': { icon: 'location', color: 'text-rose-400' },
|
||||
'web_search': { icon: 'search', color: 'text-emerald-400' }
|
||||
};
|
||||
return icons[name] || { icon: 'tool', color: 'text-gray-400' };
|
||||
}
|
||||
|
||||
// Get implementation icon
|
||||
function getImplementationIcon(impl: string): { icon: string; color: string; bg: string } {
|
||||
const icons: Record<string, { icon: string; color: string; bg: string }> = {
|
||||
'javascript': { icon: 'js', color: 'text-yellow-300', bg: 'bg-yellow-900/30' },
|
||||
'python': { icon: 'py', color: 'text-blue-300', bg: 'bg-blue-900/30' },
|
||||
'http': { icon: 'http', color: 'text-purple-300', bg: 'bg-purple-900/30' }
|
||||
};
|
||||
return icons[impl] || { icon: '?', color: 'text-gray-300', bg: 'bg-gray-900/30' };
|
||||
}
|
||||
|
||||
// Format parameters with type info
|
||||
function getParameters(def: ToolDefinition): Array<{ name: string; type: string; required: boolean; description?: string }> {
|
||||
const params = def.function.parameters;
|
||||
if (!params.properties) return [];
|
||||
|
||||
return Object.entries(params.properties).map(([name, prop]) => ({
|
||||
name,
|
||||
type: prop.type,
|
||||
required: params.required?.includes(name) ?? false,
|
||||
description: prop.description
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if description is long
|
||||
function isLongDescription(text: string): boolean {
|
||||
return text.length > 150;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-theme-primary">Tools</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Extend AI capabilities with built-in and custom tools
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-theme-muted">Tools enabled</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleGlobalTools}
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-theme-primary {toolsState.toolsEnabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
|
||||
role="switch"
|
||||
aria-checked={toolsState.toolsEnabled}
|
||||
>
|
||||
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {toolsState.toolsEnabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mb-6 grid grid-cols-4 gap-4">
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Total Tools</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-theme-primary">{stats.total}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Enabled</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-emerald-400">{stats.enabled}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Built-in</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-blue-400">{stats.builtin}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Custom</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-violet-400">{stats.custom}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-6">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search tools..."
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-sm text-theme-primary placeholder:text-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => searchQuery = ''}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-theme-muted hover:text-theme-primary"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Built-in Tools -->
|
||||
<section class="mb-8">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Built-in Tools
|
||||
<span class="text-sm font-normal text-theme-muted">({filteredBuiltinTools.length})</span>
|
||||
</h3>
|
||||
|
||||
{#if filteredBuiltinTools.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||
<p class="text-sm text-theme-muted">No tools match your search</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each filteredBuiltinTools as tool (tool.definition.function.name)}
|
||||
{@const toolIcon = getToolIcon(tool.definition.function.name)}
|
||||
{@const params = getParameters(tool.definition)}
|
||||
{@const isLong = isLongDescription(tool.definition.function.description)}
|
||||
{@const isExpanded = expandedDescriptions.has(tool.definition.function.name)}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary transition-all {tool.enabled ? '' : 'opacity-50'}">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Tool Icon -->
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-theme-tertiary {toolIcon.color}">
|
||||
{#if toolIcon.icon === 'clock'}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{:else if toolIcon.icon === 'calculator'}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 15.75V18m-7.5-6.75h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V13.5zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V18zm2.498-6.75h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V13.5zm0 2.25h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V18zm2.504-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V18zm2.498-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zM8.25 6h7.5v2.25h-7.5V6zM12 2.25c-1.892 0-3.758.11-5.593.322C5.307 2.7 4.5 3.65 4.5 4.757V19.5a2.25 2.25 0 002.25 2.25h10.5a2.25 2.25 0 002.25-2.25V4.757c0-1.108-.806-2.057-1.907-2.185A48.507 48.507 0 0012 2.25z" />
|
||||
</svg>
|
||||
{:else if toolIcon.icon === 'globe'}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
{:else if toolIcon.icon === 'location'}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
{:else if toolIcon.icon === 'search'}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-mono text-sm font-semibold text-theme-primary">{tool.definition.function.name}</h4>
|
||||
<span class="rounded-full bg-blue-900/40 px-2 py-0.5 text-xs font-medium text-blue-300">built-in</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-theme-muted {isLong && !isExpanded ? 'line-clamp-2' : ''}">
|
||||
{tool.definition.function.description}
|
||||
</p>
|
||||
{#if isLong}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleDescription(tool.definition.function.name)}
|
||||
class="mt-1 text-xs text-violet-400 hover:text-violet-300"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleTool(tool.definition.function.name)}
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
disabled={!toolsState.toolsEnabled}
|
||||
>
|
||||
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Parameters -->
|
||||
{#if params.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-2 border-t border-theme pt-3">
|
||||
{#each params as param}
|
||||
<div class="flex items-center gap-1 rounded-md bg-theme-tertiary px-2 py-1" title={param.description || ''}>
|
||||
<span class="font-mono text-xs text-theme-primary">{param.name}</span>
|
||||
{#if param.required}
|
||||
<span class="text-xs text-rose-400">*</span>
|
||||
{/if}
|
||||
<span class="text-xs text-theme-muted">:</span>
|
||||
<span class="rounded bg-theme-hover px-1 text-xs text-cyan-400">{param.type}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Custom Tools -->
|
||||
<section>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
Custom Tools
|
||||
<span class="text-sm font-normal text-theme-muted">({filteredCustomTools.length})</span>
|
||||
</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="flex items-center gap-2 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Tool
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if filteredCustomTools.length === 0 && toolsState.customTools.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
<h4 class="mt-4 text-sm font-medium text-theme-secondary">No custom tools yet</h4>
|
||||
<p class="mt-1 text-sm text-theme-muted">Create JavaScript, Python, or HTTP tools to extend AI capabilities</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg border border-violet-500 px-4 py-2 text-sm font-medium text-violet-400 transition-colors hover:bg-violet-900/30"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Your First Tool
|
||||
</button>
|
||||
</div>
|
||||
{:else if filteredCustomTools.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||
<p class="text-sm text-theme-muted">No custom tools match your search</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each filteredCustomTools as tool (tool.id)}
|
||||
{@const implIcon = getImplementationIcon(tool.implementation)}
|
||||
{@const customParams = Object.entries(tool.parameters.properties ?? {})}
|
||||
{@const isLong = isLongDescription(tool.description)}
|
||||
{@const isExpanded = expandedDescriptions.has(tool.id)}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary transition-all {tool.enabled ? '' : 'opacity-50'}">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Implementation Icon -->
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg {implIcon.bg}">
|
||||
{#if tool.implementation === 'javascript'}
|
||||
<span class="font-mono text-sm font-bold {implIcon.color}">JS</span>
|
||||
{:else if tool.implementation === 'python'}
|
||||
<span class="font-mono text-sm font-bold {implIcon.color}">PY</span>
|
||||
{:else}
|
||||
<svg class="h-5 w-5 {implIcon.color}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-mono text-sm font-semibold text-theme-primary">{tool.name}</h4>
|
||||
<span class="rounded-full bg-violet-900/40 px-2 py-0.5 text-xs font-medium text-violet-300">custom</span>
|
||||
<span class="rounded-full {implIcon.bg} px-2 py-0.5 text-xs font-medium {implIcon.color}">{tool.implementation}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-theme-muted {isLong && !isExpanded ? 'line-clamp-2' : ''}">
|
||||
{tool.description}
|
||||
</p>
|
||||
{#if isLong}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleDescription(tool.id)}
|
||||
class="mt-1 text-xs text-violet-400 hover:text-violet-300"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditEditor(tool)}
|
||||
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||
aria-label="Edit tool"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDeleteTool(tool)}
|
||||
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
|
||||
aria-label="Delete tool"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleTool(tool.name)}
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
disabled={!toolsState.toolsEnabled}
|
||||
>
|
||||
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameters -->
|
||||
{#if customParams.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-2 border-t border-theme pt-3">
|
||||
{#each customParams as [name, prop]}
|
||||
<div class="flex items-center gap-1 rounded-md bg-theme-tertiary px-2 py-1" title={prop.description || ''}>
|
||||
<span class="font-mono text-xs text-theme-primary">{name}</span>
|
||||
{#if tool.parameters.required?.includes(name)}
|
||||
<span class="text-xs text-rose-400">*</span>
|
||||
{/if}
|
||||
<span class="text-xs text-theme-muted">:</span>
|
||||
<span class="rounded bg-theme-hover px-1 text-xs text-cyan-400">{prop.type}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Info Section -->
|
||||
<section class="mt-8 rounded-lg border border-theme bg-gradient-to-br from-theme-secondary/80 to-theme-secondary/40 p-5">
|
||||
<h4 class="flex items-center gap-2 text-sm font-semibold text-theme-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
How Tools Work
|
||||
</h4>
|
||||
<p class="mt-3 text-sm text-theme-muted leading-relaxed">
|
||||
Tools extend the AI's capabilities by allowing it to perform actions beyond text generation.
|
||||
When you ask a question that could benefit from a tool, the AI will automatically select and use the appropriate one.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-lg bg-theme-tertiary/50 p-3">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-yellow-400">
|
||||
<span class="font-mono">JS</span>
|
||||
JavaScript
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">Runs in browser, instant execution</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-theme-tertiary/50 p-3">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-blue-400">
|
||||
<span class="font-mono">PY</span>
|
||||
Python
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">Runs on backend server</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-theme-tertiary/50 p-3">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-purple-400">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
HTTP
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">Calls external APIs</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-theme-muted">
|
||||
<strong class="text-theme-secondary">Note:</strong> Not all models support tool calling. Models like Llama 3.1+, Mistral 7B+, and Qwen have built-in tool support.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ToolEditor
|
||||
isOpen={showEditor}
|
||||
editingTool={editingTool}
|
||||
onClose={() => { showEditor = false; editingTool = null; }}
|
||||
onSave={handleSaveTool}
|
||||
/>
|
||||
13
frontend/src/lib/components/settings/index.ts
Normal file
13
frontend/src/lib/components/settings/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Settings components barrel export
|
||||
*/
|
||||
export { default as SettingsTabs } from './SettingsTabs.svelte';
|
||||
export { default as GeneralTab } from './GeneralTab.svelte';
|
||||
export { default as ModelsTab } from './ModelsTab.svelte';
|
||||
export { default as PromptsTab } from './PromptsTab.svelte';
|
||||
export { default as ToolsTab } from './ToolsTab.svelte';
|
||||
export { default as KnowledgeTab } from './KnowledgeTab.svelte';
|
||||
export { default as MemoryTab } from './MemoryTab.svelte';
|
||||
export { default as ModelParametersPanel } from './ModelParametersPanel.svelte';
|
||||
|
||||
export type { SettingsTab } from './SettingsTabs.svelte';
|
||||
6
frontend/src/routes/knowledge/+page.server.ts
Normal file
6
frontend/src/routes/knowledge/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
redirect(301, '/settings?tab=knowledge');
|
||||
};
|
||||
6
frontend/src/routes/models/+page.server.ts
Normal file
6
frontend/src/routes/models/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
redirect(301, '/settings?tab=models');
|
||||
};
|
||||
6
frontend/src/routes/prompts/+page.server.ts
Normal file
6
frontend/src/routes/prompts/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
redirect(301, '/settings?tab=prompts');
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
6
frontend/src/routes/tools/+page.server.ts
Normal file
6
frontend/src/routes/tools/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
redirect(301, '/settings?tab=tools');
|
||||
};
|
||||
Reference in New Issue
Block a user