TypeScript error fixes: - Fix UUID mock type in chunker.test.ts - Remove invalid timestamp property from Message types in tests - Fix mockFetch type in client.test.ts - Add missing parameters property to tool definition in test Accessibility fixes (109 → 40 warnings, remaining are CSS @apply): - Add aria-labels to all toggle switches and icon-only buttons - Add tabindex="-1" to all dialog elements with role="dialog" - Add onkeydown handlers to modal backdrops for keyboard accessibility - Fix form labels: change decorative labels to spans, use fieldset/legend for groups - Convert fileInput variables to $state() for proper reactivity - Fix closure captures in ThinkingBlock and HtmlPreview with $derived() - Add role="region" to drag-and-drop zones - Restore keyboard navigation to BranchNavigator All 547 tests pass.
376 lines
16 KiB
Svelte
376 lines
16 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* MemoryTab - Model parameters, embedding model, auto-compact, and model-prompt defaults
|
|
*/
|
|
import { onMount } from 'svelte';
|
|
import { modelsState, settingsState, promptsState } from '$lib/stores';
|
|
import { modelPromptMappingsState } from '$lib/stores/model-prompt-mappings.svelte.js';
|
|
import { modelInfoService, type ModelInfo } from '$lib/services/model-info-service.js';
|
|
import { PARAMETER_RANGES, PARAMETER_LABELS, PARAMETER_DESCRIPTIONS, AUTO_COMPACT_RANGES } from '$lib/types/settings';
|
|
import { EMBEDDING_MODELS } from '$lib/memory/embeddings';
|
|
|
|
// Model info cache for the settings page
|
|
let modelInfoCache = $state<Map<string, ModelInfo>>(new Map());
|
|
let isLoadingModelInfo = $state(false);
|
|
|
|
// Load model info for all available models
|
|
onMount(async () => {
|
|
isLoadingModelInfo = true;
|
|
try {
|
|
const models = modelsState.chatModels;
|
|
const infos = await Promise.all(
|
|
models.map(async (model) => {
|
|
const info = await modelInfoService.getModelInfo(model.name);
|
|
return [model.name, info] as [string, ModelInfo];
|
|
})
|
|
);
|
|
modelInfoCache = new Map(infos);
|
|
} finally {
|
|
isLoadingModelInfo = false;
|
|
}
|
|
});
|
|
|
|
// Handle prompt selection for a model
|
|
async function handleModelPromptChange(modelName: string, promptId: string | null): Promise<void> {
|
|
if (promptId === null) {
|
|
await modelPromptMappingsState.removeMapping(modelName);
|
|
} else {
|
|
await modelPromptMappingsState.setMapping(modelName, promptId);
|
|
}
|
|
}
|
|
|
|
// Get the currently mapped prompt ID for a model
|
|
function getMappedPromptId(modelName: string): string | undefined {
|
|
return modelPromptMappingsState.getMapping(modelName);
|
|
}
|
|
|
|
// Get current model defaults for reset functionality
|
|
const currentModelDefaults = $derived(
|
|
modelsState.selectedId ? modelsState.getModelDefaults(modelsState.selectedId) : undefined
|
|
);
|
|
</script>
|
|
|
|
<div class="space-y-8">
|
|
<!-- Memory Management Section -->
|
|
<section>
|
|
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
|
</svg>
|
|
Memory Management
|
|
</h2>
|
|
|
|
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
|
|
<!-- Embedding Model Selector -->
|
|
<div class="pb-4 border-b border-theme">
|
|
<label for="embedding-model" class="text-sm font-medium text-theme-secondary">Embedding Model</label>
|
|
<p class="text-xs text-theme-muted mb-2">Model used for semantic search and conversation indexing</p>
|
|
<select
|
|
id="embedding-model"
|
|
value={settingsState.embeddingModel}
|
|
onchange={(e) => settingsState.updateEmbeddingModel(e.currentTarget.value)}
|
|
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-secondary focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
>
|
|
{#each EMBEDDING_MODELS as model}
|
|
<option value={model}>{model}</option>
|
|
{/each}
|
|
</select>
|
|
{#if !modelsState.hasEmbeddingModel}
|
|
<p class="mt-2 text-xs text-amber-400">
|
|
No embedding model installed. Run <code class="bg-theme-tertiary px-1 rounded text-theme-muted">ollama pull {settingsState.embeddingModel}</code> to enable semantic search.
|
|
</p>
|
|
{:else}
|
|
{@const selectedInstalled = modelsState.embeddingModels.some(m => m.name.includes(settingsState.embeddingModel.split(':')[0]))}
|
|
{#if !selectedInstalled}
|
|
<p class="mt-2 text-xs text-amber-400">
|
|
Selected model not installed. Run <code class="bg-theme-tertiary px-1 rounded text-theme-muted">ollama pull {settingsState.embeddingModel}</code> or select an installed model.
|
|
</p>
|
|
<p class="mt-1 text-xs text-theme-muted">
|
|
Installed: {modelsState.embeddingModels.map(m => m.name).join(', ')}
|
|
</p>
|
|
{:else}
|
|
<p class="mt-2 text-xs text-emerald-400">
|
|
Model installed and ready.
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Auto-Compact Toggle -->
|
|
<div class="flex items-center justify-between pb-4 border-b border-theme">
|
|
<div>
|
|
<p class="text-sm font-medium text-theme-secondary">Auto-Compact</p>
|
|
<p class="text-xs text-theme-muted">Automatically summarize older messages when context usage is high</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={() => settingsState.toggleAutoCompact()}
|
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.autoCompactEnabled ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
|
|
role="switch"
|
|
aria-checked={settingsState.autoCompactEnabled}
|
|
aria-label="Toggle auto-compact"
|
|
>
|
|
<span
|
|
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.autoCompactEnabled ? 'translate-x-5' : 'translate-x-0'}"
|
|
></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}
|
|
aria-label="Toggle custom model parameters"
|
|
>
|
|
<span
|
|
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.useCustomParameters ? 'translate-x-5' : 'translate-x-0'}"
|
|
></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>
|