8 Commits

Author SHA1 Message Date
1063bec248 chore: bump version to 0.4.9
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-03 18:26:40 +01:00
cf4981f3b2 feat: add auto-compact, settings page, and message virtualization
- Add auto-compact feature with configurable threshold (50-90%)
- Convert settings modal to full /settings page with organized sections
- Add Memory Management settings (auto-compact toggle, threshold, preserve count)
- Add inline SummarizationIndicator shown where compaction occurred
- Add VirtualMessageList with fallback for long conversation performance
- Trigger auto-compact after assistant responses when threshold reached
2026-01-03 18:26:11 +01:00
7cc0df2c78 ci: sync GitHub release notes from Gitea 2026-01-03 15:48:50 +01:00
e19b6330e9 chore: bump version to 0.4.8
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-03 15:32:04 +01:00
c194a4e0e9 fix: include custom tools in Ollama API requests
Custom tools were displayed as enabled in the UI but never sent to
Ollama because getEnabledToolDefinitions() only queried the builtin
tool registry. Now iterates customTools and includes enabled ones.

Fixes #4
2026-01-03 15:29:25 +01:00
04c3018360 chore: bump version to 0.4.7
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 22:42:35 +01:00
2699f1cd5c fix: handle null updates array and show capabilities for local models
- Fix TypeError when check updates returns null updates array
- Display verified capabilities from Ollama runtime in Local Models tab
- Fetch all model capabilities on page mount
- Add data-dev to gitignore
2026-01-02 22:41:37 +01:00
9f313e6599 feat: verify model capabilities from Ollama runtime
Some checks failed
Create Release / release (push) Has been cancelled
- Add capability verification for installed models using /api/show
- SyncModels now updates real capabilities when fetchDetails=true
- Model browser shows verified/unverified badges for capabilities
- Add info notice that capabilities are sourced from ollama.com
- Fix incorrect capability data (e.g., deepseek-r1 "tools" badge)

Capabilities from ollama.com website may be inaccurate. Once a model
is installed, Vessel fetches actual capabilities from Ollama runtime
and displays a "verified" badge in the model details panel.
2026-01-02 22:35:03 +01:00
23 changed files with 1124 additions and 246 deletions

View File

@@ -17,11 +17,30 @@ jobs:
with:
fetch-depth: 0
- name: Wait for Gitea release
run: sleep 60
- name: Fetch release notes from Gitea
id: gitea_notes
env:
TAG_NAME: ${{ github.ref_name }}
run: |
NOTES=$(curl -s "https://somegit.dev/api/v1/repos/vikingowl/vessel/releases/tags/${TAG_NAME}" | jq -r '.body // empty')
if [ -n "$NOTES" ]; then
echo "found=true" >> $GITHUB_OUTPUT
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> $GITHUB_OUTPUT
else
echo "found=false" >> $GITHUB_OUTPUT
echo "notes=See the [full release notes on Gitea](https://somegit.dev/vikingowl/vessel/releases/tag/${TAG_NAME}) for detailed information." >> $GITHUB_OUTPUT
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
body: |
See the [full release notes on Gitea](https://somegit.dev/vikingowl/vessel/releases) for detailed information.
body: ${{ steps.gitea_notes.outputs.notes }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ CLAUDE.md
dev.env
backend/vessel-backend
data/
backend/data-dev/

View File

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

View File

@@ -491,6 +491,55 @@ func (s *ModelRegistryService) SyncModels(ctx context.Context, fetchDetails bool
count++
}
// If fetchDetails is true and we have an Ollama client, update capabilities
// for installed models using the actual /api/show response (more accurate than scraped data)
if fetchDetails && s.ollamaClient != nil {
installedModels, err := s.ollamaClient.List(ctx)
if err != nil {
log.Printf("Warning: failed to list installed models for capability sync: %v", err)
} else {
log.Printf("Syncing capabilities for %d installed models", len(installedModels.Models))
for _, installed := range installedModels.Models {
select {
case <-ctx.Done():
return count, ctx.Err()
default:
}
// Extract base model name (e.g., "deepseek-r1" from "deepseek-r1:14b")
modelName := installed.Model
baseName := strings.Split(modelName, ":")[0]
// Fetch real capabilities from Ollama
details, err := s.fetchModelDetails(ctx, modelName)
if err != nil {
log.Printf("Warning: failed to fetch details for %s: %v", modelName, err)
continue
}
// Extract capabilities from the actual Ollama response
capabilities := []string{}
if details.Capabilities != nil {
for _, cap := range details.Capabilities {
capabilities = append(capabilities, string(cap))
}
}
capsJSON, _ := json.Marshal(capabilities)
// Update capabilities for the base model name
_, err = s.db.ExecContext(ctx, `
UPDATE remote_models SET capabilities = ? WHERE slug = ?
`, string(capsJSON), baseName)
if err != nil {
log.Printf("Warning: failed to update capabilities for %s: %v", baseName, err)
} else {
log.Printf("Updated capabilities for %s: %v", baseName, capabilities)
}
}
}
}
return count, nil
}

View File

@@ -1,12 +1,12 @@
{
"name": "vessel",
"version": "0.3.0",
"version": "0.4.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vessel",
"version": "0.3.0",
"version": "0.4.8",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
@@ -15,6 +15,8 @@
"@skeletonlabs/skeleton": "^2.10.0",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-node": "^5.4.0",
"@tanstack/svelte-virtual": "^3.13.15",
"@tanstack/virtual-core": "^3.13.15",
"@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1",
"dexie": "^4.0.10",
@@ -1739,6 +1741,32 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tanstack/svelte-virtual": {
"version": "3.13.15",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-virtual/-/svelte-virtual-3.13.15.tgz",
"integrity": "sha512-3PPLI3hsyT70zSZhBkSIZXIarlN+GjFNKeKr2Wk1UR7EuEVtXgNlB/Zk0sYtaeJ4CvGvldQNakOvbdETnWAgeA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.15"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"svelte": "^3.48.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.15",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.15.tgz",
"integrity": "sha512-8cG3acM2cSIm3h8WxboHARAhQAJbYUhvmadvnN8uz8aziDwrbYb9KiARni+uY2qrLh49ycn+poGoxvtIAKhjog==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"dev": true,

View File

@@ -1,6 +1,6 @@
{
"name": "vessel",
"version": "0.4.5",
"version": "0.4.9",
"private": true,
"type": "module",
"scripts": {
@@ -37,10 +37,12 @@
"@codemirror/lang-python": "^6.1.7",
"@codemirror/theme-one-dark": "^6.1.2",
"@skeletonlabs/skeleton": "^2.10.0",
"codemirror": "^6.0.1",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-node": "^5.4.0",
"@tanstack/svelte-virtual": "^3.13.15",
"@tanstack/virtual-core": "^3.13.15",
"@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1",
"dexie": "^4.0.10",
"dompurify": "^3.2.0",
"marked": "^15.0.0",

View File

@@ -165,9 +165,11 @@ export async function fetchTagSizes(slug: string): Promise<RemoteModel> {
/**
* Sync models from ollama.com
* @param fetchDetails - If true, also fetches real capabilities from Ollama for installed models
*/
export async function syncModels(): Promise<SyncResponse> {
const response = await fetch(`${API_BASE}/remote/sync`, {
export async function syncModels(fetchDetails: boolean = true): Promise<SyncResponse> {
const url = fetchDetails ? `${API_BASE}/remote/sync?details=true` : `${API_BASE}/remote/sync`;
const response = await fetch(url, {
method: 'POST'
});

View File

@@ -22,7 +22,7 @@
import { runToolCalls, formatToolResultsForChat, getFunctionModel, USE_FUNCTION_MODEL } from '$lib/tools';
import type { OllamaMessage, OllamaToolCall, OllamaToolDefinition } from '$lib/ollama';
import type { Conversation } from '$lib/types/conversation';
import MessageList from './MessageList.svelte';
import VirtualMessageList from './VirtualMessageList.svelte';
import ChatInput from './ChatInput.svelte';
import EmptyState from './EmptyState.svelte';
import ContextUsageBar from './ContextUsageBar.svelte';
@@ -271,6 +271,49 @@
}
}
/**
* Handle automatic compaction of older messages
* Called after assistant response completes when auto-compact is enabled
*/
async function handleAutoCompact(): Promise<void> {
// Check if auto-compact should be triggered
if (!contextManager.shouldAutoCompact()) return;
const selectedModel = modelsState.selectedId;
if (!selectedModel || isSummarizing) return;
const messages = chatState.visibleMessages;
const preserveCount = contextManager.getAutoCompactPreserveCount();
const { toSummarize } = selectMessagesForSummarization(messages, 0, preserveCount);
if (toSummarize.length < 2) return;
isSummarizing = true;
try {
// Generate summary using the LLM
const summary = await generateSummary(toSummarize, selectedModel);
// Mark original messages as summarized
const messageIdsToSummarize = toSummarize.map((node) => node.id);
chatState.markAsSummarized(messageIdsToSummarize);
// Insert the summary message (inline indicator will be shown by MessageList)
chatState.insertSummaryMessage(summary);
// Force context recalculation
contextManager.updateMessages(chatState.visibleMessages, true);
// Subtle notification for auto-compact (inline indicator is the primary feedback)
console.log(`[Auto-compact] Summarized ${toSummarize.length} messages`);
} catch (error) {
console.error('[Auto-compact] Failed:', error);
// Silent failure for auto-compact - don't interrupt user flow
} finally {
isSummarizing = false;
}
}
// =========================================================================
// Context Full Modal Handlers
// =========================================================================
@@ -549,6 +592,9 @@
conversationsState.update(conversationId, {});
}
}
// Check for auto-compact after response completes
await handleAutoCompact();
},
onError: (error) => {
console.error('Streaming error:', error);
@@ -826,7 +872,7 @@
<div class="flex h-full flex-col bg-theme-primary">
{#if hasMessages}
<div class="flex-1 overflow-hidden">
<MessageList
<VirtualMessageList
onRegenerate={handleRegenerate}
onEditMessage={handleEditMessage}
showThinking={thinkingEnabled}

View File

@@ -7,6 +7,7 @@
import { chatState } from '$lib/stores';
import type { MessageNode, BranchInfo } from '$lib/types';
import MessageItem from './MessageItem.svelte';
import SummarizationIndicator from './SummarizationIndicator.svelte';
interface Props {
onRegenerate?: () => void;
@@ -208,6 +209,10 @@
>
<div class="mx-auto max-w-4xl px-4 py-6">
{#each chatState.visibleMessages as node, index (node.id)}
<!-- Show summarization indicator before summary messages -->
{#if node.message.isSummary}
<SummarizationIndicator />
{/if}
<MessageItem
{node}
branchInfo={getBranchInfo(node)}

View File

@@ -0,0 +1,17 @@
<script lang="ts">
/**
* SummarizationIndicator - Visual marker showing where conversation was summarized
* Displayed in the message list to indicate context compaction occurred
*/
</script>
<div class="flex items-center gap-3 py-4" role="separator" aria-label="Conversation summarized">
<div class="flex-1 border-t border-dashed border-emerald-500/30"></div>
<div class="flex items-center gap-2 text-xs text-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="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 4" />
</svg>
<span>Earlier messages summarized</span>
</div>
<div class="flex-1 border-t border-dashed border-emerald-500/30"></div>
</div>

View File

@@ -0,0 +1,314 @@
<script lang="ts">
/**
* VirtualMessageList - Virtualized message list for large conversations
* Only renders visible messages for performance with long chats
*
* Uses @tanstack/svelte-virtual for virtualization.
* Falls back to regular rendering if virtualization fails.
*/
import { createVirtualizer } from '@tanstack/svelte-virtual';
import { chatState } from '$lib/stores';
import type { MessageNode, BranchInfo } from '$lib/types';
import MessageItem from './MessageItem.svelte';
import SummarizationIndicator from './SummarizationIndicator.svelte';
import { onMount } from 'svelte';
interface Props {
onRegenerate?: () => void;
onEditMessage?: (messageId: string, newContent: string) => void;
showThinking?: boolean;
}
const { onRegenerate, onEditMessage, showThinking = true }: Props = $props();
// Container reference
let scrollContainer: HTMLDivElement | null = $state(null);
// Track if component is mounted (scroll container available)
let isMounted = $state(false);
// Track user scroll state
let userScrolledAway = $state(false);
let autoScrollEnabled = $state(true);
let wasStreaming = false;
// Height cache for measured items (message ID -> height)
const heightCache = new Map<string, number>();
// Default estimated height for messages
const DEFAULT_ITEM_HEIGHT = 150;
// Threshold for scroll detection
const SCROLL_THRESHOLD = 100;
// Get visible messages
const messages = $derived(chatState.visibleMessages);
// Set mounted after component mounts
onMount(() => {
isMounted = true;
});
// Create virtualizer - only functional after mount when scrollContainer exists
const virtualizer = createVirtualizer({
get count() {
return messages.length;
},
getScrollElement: () => scrollContainer,
estimateSize: (index: number) => {
const msg = messages[index];
if (!msg) return DEFAULT_ITEM_HEIGHT;
return heightCache.get(msg.id) ?? DEFAULT_ITEM_HEIGHT;
},
overscan: 5,
});
// Get virtual items with fallback
const virtualItems = $derived.by(() => {
if (!isMounted || !scrollContainer) {
return [];
}
return $virtualizer.getVirtualItems();
});
// Check if we should use fallback (non-virtual) rendering
const useFallback = $derived(
messages.length > 0 && virtualItems.length === 0 && isMounted
);
// Track conversation changes to clear cache
let lastConversationId: string | null = null;
$effect(() => {
const currentId = chatState.conversationId;
if (currentId !== lastConversationId) {
heightCache.clear();
lastConversationId = currentId;
}
});
// Force measure after mount and when scroll container becomes available
$effect(() => {
if (isMounted && scrollContainer && messages.length > 0) {
// Use setTimeout to ensure DOM is fully ready
setTimeout(() => {
$virtualizer.measure();
}, 0);
}
});
// Handle streaming scroll behavior
$effect(() => {
const isStreaming = chatState.isStreaming;
if (isStreaming && !wasStreaming) {
autoScrollEnabled = true;
if (!userScrolledAway && scrollContainer) {
requestAnimationFrame(() => {
if (useFallback) {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight });
} else {
$virtualizer.scrollToIndex(messages.length - 1, { align: 'end' });
}
});
}
}
wasStreaming = isStreaming;
});
// Scroll to bottom during streaming
$effect(() => {
const buffer = chatState.streamBuffer;
const isStreaming = chatState.isStreaming;
if (isStreaming && buffer && autoScrollEnabled && scrollContainer) {
requestAnimationFrame(() => {
if (useFallback) {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight });
} else {
$virtualizer.scrollToIndex(messages.length - 1, { align: 'end' });
}
});
}
});
// Scroll when new messages are added
let previousMessageCount = 0;
$effect(() => {
const currentCount = messages.length;
if (currentCount > previousMessageCount && currentCount > 0 && scrollContainer) {
autoScrollEnabled = true;
userScrolledAway = false;
requestAnimationFrame(() => {
if (useFallback) {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight });
} else {
$virtualizer.scrollToIndex(currentCount - 1, { align: 'end' });
}
});
}
previousMessageCount = currentCount;
});
// Handle scroll events
function handleScroll(): void {
if (!scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
userScrolledAway = scrollHeight - scrollTop - clientHeight > SCROLL_THRESHOLD;
if (userScrolledAway && chatState.isStreaming) {
autoScrollEnabled = false;
}
}
// Scroll to bottom button handler
function scrollToBottom(): void {
if (!scrollContainer) return;
if (useFallback) {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
} else if (messages.length > 0) {
$virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'smooth' });
}
}
// Measure item height after render (for virtualized mode)
function measureItem(node: HTMLElement, index: number) {
const msg = messages[index];
if (!msg) return { destroy: () => {} };
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = entry.contentRect.height;
if (height > 0 && heightCache.get(msg.id) !== height) {
heightCache.set(msg.id, height);
$virtualizer.measure();
}
}
});
resizeObserver.observe(node);
// Initial measurement
const height = node.getBoundingClientRect().height;
if (height > 0) {
heightCache.set(msg.id, height);
}
return {
destroy() {
resizeObserver.disconnect();
}
};
}
// Get branch info for a message
function getBranchInfo(node: MessageNode): BranchInfo | null {
const info = chatState.getBranchInfo(node.id);
if (info && info.totalCount > 1) {
return info;
}
return null;
}
// Handle branch switch
function handleBranchSwitch(messageId: string, direction: 'prev' | 'next'): void {
chatState.switchBranch(messageId, direction);
}
// Check if message is streaming
function isStreamingMessage(node: MessageNode): boolean {
return chatState.isStreaming && chatState.streamingMessageId === node.id;
}
// Check if message is last
function isLastMessage(index: number): boolean {
return index === messages.length - 1;
}
// Show scroll button
const showScrollButton = $derived(userScrolledAway && messages.length > 0);
</script>
<div class="relative h-full">
<div
bind:this={scrollContainer}
onscroll={handleScroll}
class="h-full overflow-y-auto"
role="log"
aria-live="polite"
aria-label="Chat messages"
>
<div class="mx-auto max-w-4xl px-4 py-6">
{#if useFallback}
<!-- Fallback: Regular rendering when virtualization isn't working -->
{#each messages as node, index (node.id)}
{#if node.message.isSummary}
<SummarizationIndicator />
{/if}
<MessageItem
{node}
branchInfo={getBranchInfo(node)}
isStreaming={isStreamingMessage(node)}
isLast={isLastMessage(index)}
{showThinking}
onBranchSwitch={(direction) => handleBranchSwitch(node.id, direction)}
onRegenerate={onRegenerate}
onEdit={(newContent) => onEditMessage?.(node.id, newContent)}
/>
{/each}
{:else}
<!-- Virtualized rendering -->
<div
style="height: {$virtualizer.getTotalSize()}px; width: 100%; position: relative;"
>
{#each virtualItems as virtualRow (virtualRow.key)}
{@const node = messages[virtualRow.index]}
{@const index = virtualRow.index}
{#if node}
<div
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({virtualRow.start}px);"
use:measureItem={index}
>
{#if node.message.isSummary}
<SummarizationIndicator />
{/if}
<MessageItem
{node}
branchInfo={getBranchInfo(node)}
isStreaming={isStreamingMessage(node)}
isLast={isLastMessage(index)}
{showThinking}
onBranchSwitch={(direction) => handleBranchSwitch(node.id, direction)}
onRegenerate={onRegenerate}
onEdit={(newContent) => onEditMessage?.(node.id, newContent)}
/>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
<!-- Scroll to bottom button -->
{#if showScrollButton}
<button
type="button"
onclick={scrollToBottom}
class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-theme-tertiary px-4 py-2 text-sm text-theme-secondary shadow-lg transition-all hover:bg-theme-secondary"
aria-label="Scroll to latest message"
>
<span class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a1 1 0 01-.707-.293l-5-5a1 1 0 011.414-1.414L10 15.586l4.293-4.293a1 1 0 011.414 1.414l-5 5A1 1 0 0110 18z" clip-rule="evenodd" />
</svg>
Jump to latest
</span>
</button>
{/if}
</div>

View File

@@ -8,13 +8,9 @@
import SidenavHeader from './SidenavHeader.svelte';
import SidenavSearch from './SidenavSearch.svelte';
import ConversationList from './ConversationList.svelte';
import { SettingsModal } from '$lib/components/shared';
// Check if a path is active
const isActive = (path: string) => $page.url.pathname === path;
// Settings modal state
let settingsOpen = $state(false);
</script>
<!-- Overlay for mobile (closes sidenav when clicking outside) -->
@@ -137,11 +133,10 @@
<span>Prompts</span>
</a>
<!-- Settings button -->
<button
type="button"
onclick={() => (settingsOpen = true)}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-hover hover:text-theme-primary"
<!-- Settings link -->
<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'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -159,10 +154,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span>Settings</span>
</button>
</a>
</div>
</div>
</aside>
<!-- Settings Modal -->
<SettingsModal isOpen={settingsOpen} onClose={() => (settingsOpen = false)} />

View File

@@ -1,203 +0,0 @@
<script lang="ts">
/**
* SettingsModal - Application settings dialog
* Handles theme, model defaults, and other preferences
*/
import { modelsState, uiState } from '$lib/stores';
import { getPrimaryModifierDisplay } from '$lib/utils';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const { isOpen, onClose }: Props = $props();
// Settings state (mirrors global state for editing)
let defaultModel = $state<string | null>(null);
// Sync with global state when modal opens
$effect(() => {
if (isOpen) {
defaultModel = modelsState.selectedId;
}
});
/**
* Save settings and close modal
*/
function handleSave(): void {
if (defaultModel) {
modelsState.select(defaultModel);
}
onClose();
}
/**
* Handle backdrop click
*/
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
onClose();
}
}
/**
* Handle escape key
*/
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
const modifierKey = getPrimaryModifierDisplay();
</script>
{#if isOpen}
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<!-- Modal -->
<div
class="w-full max-w-lg rounded-xl bg-theme-secondary shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<h2 id="settings-title" class="text-lg font-semibold text-theme-primary">Settings</h2>
<button
type="button"
onclick={onClose}
class="rounded-lg p-1.5 text-theme-muted hover:bg-theme-tertiary hover:text-theme-secondary"
aria-label="Close settings"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-6 p-6">
<!-- Appearance Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">Appearance</h3>
<div class="space-y-4">
<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-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {uiState.darkMode ? 'bg-emerald-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>
<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-tertiary"
>
Sync with System
</button>
</div>
</div>
</section>
<!-- Model Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">Default Model</h3>
<div class="space-y-4">
<div>
<select
bind:value={defaultModel}
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 modelsState.chatModels as model}
<option value={model.name}>{model.name}</option>
{/each}
</select>
<p class="mt-1 text-sm text-theme-muted">Model used for new conversations</p>
</div>
</div>
</section>
<!-- Keyboard Shortcuts Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">Keyboard Shortcuts</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between text-theme-secondary">
<span>New Chat</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">{modifierKey}+N</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>Search</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">{modifierKey}+K</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>Toggle Sidebar</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">{modifierKey}+B</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>Send Message</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">Enter</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>New Line</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">Shift+Enter</kbd>
</div>
</div>
</section>
<!-- About Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">About</h3>
<div class="rounded-lg bg-theme-tertiary/50 p-4">
<p class="font-medium text-theme-secondary">Vessel</p>
<p class="mt-1 text-sm text-theme-muted">
A modern interface for local AI with chat, tools, and memory management.
</p>
</div>
</section>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
<button
type="button"
onclick={onClose}
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary hover:bg-theme-tertiary"
>
Cancel
</button>
<button
type="button"
onclick={handleSave}
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500"
>
Save Changes
</button>
</div>
</div>
</div>
{/if}

View File

@@ -10,6 +10,5 @@ export { default as ToastContainer } from './ToastContainer.svelte';
export { default as Skeleton } from './Skeleton.svelte';
export { default as MessageSkeleton } from './MessageSkeleton.svelte';
export { default as ErrorBoundary } from './ErrorBoundary.svelte';
export { default as SettingsModal } from './SettingsModal.svelte';
export { default as ShortcutsModal } from './ShortcutsModal.svelte';
export { default as SearchModal } from './SearchModal.svelte';

View File

@@ -9,6 +9,7 @@ import type { MessageNode } from '$lib/types/chat.js';
import type { ContextUsage, TokenEstimate, MessageWithTokens } from './types.js';
import { estimateMessageTokens, estimateFormatOverhead, formatTokenCount } from './tokenizer.js';
import { getModelContextLimit, formatContextSize } from './model-limits.js';
import { settingsState } from '$lib/stores/settings.svelte.js';
/** Warning threshold as percentage of context (0.85 = 85%) */
const WARNING_THRESHOLD = 0.85;
@@ -252,6 +253,43 @@ class ContextManager {
this.tokenCache.clear();
this.messagesWithTokens = [];
}
/**
* Check if auto-compact should be triggered
* Returns true if:
* - Auto-compact is enabled in settings
* - Context usage exceeds the configured threshold
* - There are enough messages to summarize
*/
shouldAutoCompact(): boolean {
// Check if auto-compact is enabled
if (!settingsState.autoCompactEnabled) {
return false;
}
// Check context usage against threshold
const threshold = settingsState.autoCompactThreshold;
if (this.contextUsage.percentage < threshold) {
return false;
}
// Check if there are enough messages to summarize
// Need at least preserveCount + 2 messages to have anything to summarize
const preserveCount = settingsState.autoCompactPreserveCount;
const minMessages = preserveCount + 2;
if (this.messagesWithTokens.length < minMessages) {
return false;
}
return true;
}
/**
* Get the number of recent messages to preserve during auto-compact
*/
getAutoCompactPreserveCount(): number {
return settingsState.autoCompactPreserveCount;
}
}
/** Singleton context manager instance */

View File

@@ -79,18 +79,22 @@ export async function generateSummary(
/**
* Determine which messages should be summarized
* Returns indices of messages to summarize (older messages) and messages to keep
* @param messages - All messages in the conversation
* @param targetFreeTokens - Not currently used (preserved for API compatibility)
* @param preserveCount - Number of recent messages to keep (defaults to PRESERVE_RECENT_MESSAGES)
*/
export function selectMessagesForSummarization(
messages: MessageNode[],
targetFreeTokens: number
targetFreeTokens: number,
preserveCount: number = PRESERVE_RECENT_MESSAGES
): { toSummarize: MessageNode[]; toKeep: MessageNode[] } {
if (messages.length <= PRESERVE_RECENT_MESSAGES) {
if (messages.length <= preserveCount) {
return { toSummarize: [], toKeep: messages };
}
// Calculate how many messages to summarize
// Keep the recent ones, summarize the rest
const cutoffIndex = Math.max(0, messages.length - PRESERVE_RECENT_MESSAGES);
const cutoffIndex = Math.max(0, messages.length - preserveCount);
// Filter out system messages from summarization (they should stay)
const toSummarize: MessageNode[] = [];

View File

@@ -9,6 +9,7 @@ export { UIState, uiState } from './ui.svelte.js';
export { ToastState, toastState } from './toast.svelte.js';
export { toolsState } from './tools.svelte.js';
export { promptsState } from './prompts.svelte.js';
export { SettingsState, settingsState } from './settings.svelte.js';
export type { Prompt } from './prompts.svelte.js';
export { VersionState, versionState } from './version.svelte.js';

View File

@@ -146,7 +146,8 @@ class LocalModelsState {
const response = await checkForUpdates();
this.updatesAvailable = response.updatesAvailable;
this.modelsWithUpdates = new Set(response.updates.map(m => m.name));
// Handle null/undefined updates array from API
this.modelsWithUpdates = new Set((response.updates ?? []).map(m => m.name));
return response;
} catch (err) {

View File

@@ -6,9 +6,12 @@
import {
type ModelParameters,
type ChatSettings,
type AutoCompactSettings,
DEFAULT_MODEL_PARAMETERS,
DEFAULT_CHAT_SETTINGS,
PARAMETER_RANGES
DEFAULT_AUTO_COMPACT_SETTINGS,
PARAMETER_RANGES,
AUTO_COMPACT_RANGES
} from '$lib/types/settings';
import type { ModelDefaults } from './models.svelte';
@@ -30,6 +33,11 @@ export class SettingsState {
// Panel visibility
isPanelOpen = $state(false);
// Auto-compact settings
autoCompactEnabled = $state(DEFAULT_AUTO_COMPACT_SETTINGS.enabled);
autoCompactThreshold = $state(DEFAULT_AUTO_COMPACT_SETTINGS.threshold);
autoCompactPreserveCount = $state(DEFAULT_AUTO_COMPACT_SETTINGS.preserveCount);
// Derived: Current model parameters object
modelParameters = $derived.by((): ModelParameters => ({
temperature: this.temperature,
@@ -141,6 +149,32 @@ export class SettingsState {
this.saveToStorage();
}
/**
* Toggle auto-compact enabled state
*/
toggleAutoCompact(): void {
this.autoCompactEnabled = !this.autoCompactEnabled;
this.saveToStorage();
}
/**
* Update auto-compact threshold
*/
updateAutoCompactThreshold(value: number): void {
const range = AUTO_COMPACT_RANGES.threshold;
this.autoCompactThreshold = Math.max(range.min, Math.min(range.max, value));
this.saveToStorage();
}
/**
* Update auto-compact preserve count
*/
updateAutoCompactPreserveCount(value: number): void {
const range = AUTO_COMPACT_RANGES.preserveCount;
this.autoCompactPreserveCount = Math.max(range.min, Math.min(range.max, Math.round(value)));
this.saveToStorage();
}
/**
* Load settings from localStorage
*/
@@ -151,11 +185,17 @@ export class SettingsState {
const settings: ChatSettings = JSON.parse(stored);
// Model parameters
this.useCustomParameters = settings.useCustomParameters ?? false;
this.temperature = settings.modelParameters?.temperature ?? DEFAULT_MODEL_PARAMETERS.temperature;
this.top_k = settings.modelParameters?.top_k ?? DEFAULT_MODEL_PARAMETERS.top_k;
this.top_p = settings.modelParameters?.top_p ?? DEFAULT_MODEL_PARAMETERS.top_p;
this.num_ctx = settings.modelParameters?.num_ctx ?? DEFAULT_MODEL_PARAMETERS.num_ctx;
// Auto-compact settings
this.autoCompactEnabled = settings.autoCompact?.enabled ?? DEFAULT_AUTO_COMPACT_SETTINGS.enabled;
this.autoCompactThreshold = settings.autoCompact?.threshold ?? DEFAULT_AUTO_COMPACT_SETTINGS.threshold;
this.autoCompactPreserveCount = settings.autoCompact?.preserveCount ?? DEFAULT_AUTO_COMPACT_SETTINGS.preserveCount;
} catch (error) {
console.warn('[Settings] Failed to load from localStorage:', error);
}
@@ -168,7 +208,12 @@ export class SettingsState {
try {
const settings: ChatSettings = {
useCustomParameters: this.useCustomParameters,
modelParameters: this.modelParameters
modelParameters: this.modelParameters,
autoCompact: {
enabled: this.autoCompactEnabled,
threshold: this.autoCompactThreshold,
preserveCount: this.autoCompactPreserveCount
}
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));

View File

@@ -110,8 +110,25 @@ class ToolsState {
return [];
}
const definitions = toolRegistry.getDefinitions();
return definitions.filter(def => this.isToolEnabled(def.function.name));
// Get enabled builtin tools
const builtinDefs = toolRegistry.getDefinitions();
const enabled = builtinDefs.filter(def => this.isToolEnabled(def.function.name));
// Add enabled custom tools
for (const custom of this.customTools) {
if (custom.enabled && this.isToolEnabled(custom.name)) {
enabled.push({
type: 'function',
function: {
name: custom.name,
description: custom.description,
parameters: custom.parameters
}
});
}
}
return enabled;
}
/**

View File

@@ -77,6 +77,37 @@ export const PARAMETER_DESCRIPTIONS: Record<keyof ModelParameters, string> = {
num_ctx: 'Context window size in tokens. Larger uses more memory.'
};
/**
* Auto-compact settings for automatic context management
*/
export interface AutoCompactSettings {
/** Whether auto-compact is enabled */
enabled: boolean;
/** Context usage threshold (percentage) to trigger auto-compact */
threshold: number;
/** Number of recent messages to preserve when compacting */
preserveCount: number;
}
/**
* Default auto-compact settings
*/
export const DEFAULT_AUTO_COMPACT_SETTINGS: AutoCompactSettings = {
enabled: false,
threshold: 70,
preserveCount: 6
};
/**
* Auto-compact parameter ranges for UI
*/
export const AUTO_COMPACT_RANGES = {
threshold: { min: 50, max: 90, step: 5 },
preserveCount: { min: 2, max: 20, step: 1 }
} as const;
/**
* Chat settings including model parameters
*/
@@ -86,6 +117,9 @@ export interface ChatSettings {
/** Custom model parameters (used when useCustomParameters is true) */
modelParameters: ModelParameters;
/** Auto-compact settings for context management */
autoCompact?: AutoCompactSettings;
}
/**
@@ -93,5 +127,6 @@ export interface ChatSettings {
*/
export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
useCustomParameters: false,
modelParameters: { ...DEFAULT_MODEL_PARAMETERS }
modelParameters: { ...DEFAULT_MODEL_PARAMETERS },
autoCompact: { ...DEFAULT_AUTO_COMPACT_SETTINGS }
};

View File

@@ -40,12 +40,14 @@
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); // True if capabilities come from Ollama (installed model)
async function handleSelectModel(model: RemoteModel): Promise<void> {
selectedModel = model;
selectedTag = model.tags[0] || '';
pullProgress = null;
pullError = null;
capabilitiesVerified = false;
// Fetch tag sizes if not already loaded
if (!model.tagSizes || Object.keys(model.tagSizes).length === 0) {
@@ -60,6 +62,21 @@
loadingSizes = false;
}
}
// Try to fetch real capabilities from Ollama if model is installed locally
// This overrides scraped capabilities from ollama.com with accurate runtime data
try {
const realCapabilities = await modelsState.fetchCapabilities(model.slug);
// fetchCapabilities returns empty array on error, but we check hasCapability to confirm model exists
if (modelsState.hasCapability(model.slug, 'completion') || realCapabilities.length > 0) {
// Model is installed - use real capabilities from Ollama
selectedModel = { ...selectedModel!, capabilities: realCapabilities };
capabilitiesVerified = true;
}
} catch {
// Model not installed locally - keep scraped capabilities
capabilitiesVerified = false;
}
}
function closeDetails(): void {
@@ -219,7 +236,10 @@
// Initialize stores (backend handles heavy operations)
localModelsState.init();
modelRegistry.init();
modelsState.refresh();
modelsState.refresh().then(() => {
// Fetch capabilities for all installed models
modelsState.fetchAllCapabilities();
});
});
</script>
@@ -476,6 +496,7 @@
{: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">
@@ -496,6 +517,36 @@
<span>Parameters: {model.parameterSize}</span>
<span>Quantization: {model.quantizationLevel}</span>
</div>
<!-- Capabilities (from Ollama runtime - verified) -->
{#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}
@@ -693,6 +744,14 @@
<span>☁️</span>
<span>Cloud</span>
</button>
<!-- Capability info notice -->
<span class="ml-2 text-xs text-theme-muted" title="Capability data is sourced from ollama.com and may not be accurate. Actual capabilities are verified once a model is installed locally.">
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-3.5 w-3.5 opacity-60" 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 class="opacity-60">from ollama.com</span>
</span>
</div>
<!-- Size Range Filters -->
@@ -886,14 +945,40 @@
{/if}
<!-- Capabilities -->
{#if selectedModel.capabilities.length > 0}
{#if selectedModel.capabilities.length > 0 || !capabilitiesVerified}
<div class="mb-6">
<h3 class="mb-2 text-sm font-medium text-theme-secondary">Capabilities</h3>
<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>
<h3 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" title="Capabilities verified from installed model">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
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" title="Capabilities sourced from ollama.com - install model for verified data">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
unverified
</span>
{/if}
</h3>
{#if selectedModel.capabilities.length > 0}
<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>
{:else}
<p class="text-xs text-theme-muted">No capabilities reported</p>
{/if}
{#if !capabilitiesVerified}
<p class="mt-2 text-xs text-theme-muted">
Install model to verify actual capabilities
</p>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,381 @@
<script lang="ts">
/**
* Settings page
* Comprehensive settings for appearance, models, memory, and more
*/
import { modelsState, uiState, settingsState } from '$lib/stores';
import { getPrimaryModifierDisplay } from '$lib/utils';
import { PARAMETER_RANGES, PARAMETER_LABELS, PARAMETER_DESCRIPTIONS, AUTO_COMPACT_RANGES } from '$lib/types/settings';
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);
}
}
// Get current model defaults for reset functionality
const currentModelDefaults = $derived(
modelsState.selectedId ? modelsState.getModelDefaults(modelsState.selectedId) : undefined
);
</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>
<!-- 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 Parameters Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
Model Parameters
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Use Custom Parameters Toggle -->
<div class="flex items-center justify-between pb-4 border-b border-theme">
<div>
<p class="text-sm font-medium text-theme-secondary">Use Custom Parameters</p>
<p class="text-xs text-theme-muted">Override model defaults with custom values</p>
</div>
<button
type="button"
onclick={() => settingsState.toggleCustomParameters(currentModelDefaults)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.useCustomParameters ? 'bg-orange-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={settingsState.useCustomParameters}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.useCustomParameters ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
{#if settingsState.useCustomParameters}
<!-- Temperature -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="temperature" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.temperature}</label>
<span class="text-sm text-theme-muted">{settingsState.temperature.toFixed(2)}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.temperature}</p>
<input
id="temperature"
type="range"
min={PARAMETER_RANGES.temperature.min}
max={PARAMETER_RANGES.temperature.max}
step={PARAMETER_RANGES.temperature.step}
value={settingsState.temperature}
oninput={(e) => settingsState.updateParameter('temperature', parseFloat(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Top K -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="top_k" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.top_k}</label>
<span class="text-sm text-theme-muted">{settingsState.top_k}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.top_k}</p>
<input
id="top_k"
type="range"
min={PARAMETER_RANGES.top_k.min}
max={PARAMETER_RANGES.top_k.max}
step={PARAMETER_RANGES.top_k.step}
value={settingsState.top_k}
oninput={(e) => settingsState.updateParameter('top_k', parseInt(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Top P -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="top_p" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.top_p}</label>
<span class="text-sm text-theme-muted">{settingsState.top_p.toFixed(2)}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.top_p}</p>
<input
id="top_p"
type="range"
min={PARAMETER_RANGES.top_p.min}
max={PARAMETER_RANGES.top_p.max}
step={PARAMETER_RANGES.top_p.step}
value={settingsState.top_p}
oninput={(e) => settingsState.updateParameter('top_p', parseFloat(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Context Length -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="num_ctx" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.num_ctx}</label>
<span class="text-sm text-theme-muted">{settingsState.num_ctx.toLocaleString()}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.num_ctx}</p>
<input
id="num_ctx"
type="range"
min={PARAMETER_RANGES.num_ctx.min}
max={PARAMETER_RANGES.num_ctx.max}
step={PARAMETER_RANGES.num_ctx.step}
value={settingsState.num_ctx}
oninput={(e) => settingsState.updateParameter('num_ctx', parseInt(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Reset Button -->
<div class="pt-2">
<button
type="button"
onclick={() => settingsState.resetToDefaults(currentModelDefaults)}
class="text-sm text-orange-400 hover:text-orange-300 transition-colors"
>
Reset to model defaults
</button>
</div>
{:else}
<p class="text-sm text-theme-muted py-2">
Using model defaults. Enable custom parameters to adjust temperature, sampling, and context length.
</p>
{/if}
</div>
</section>
<!-- Memory Management Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
Memory Management
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Auto-Compact Toggle -->
<div class="flex items-center justify-between pb-4 border-b border-theme">
<div>
<p class="text-sm font-medium text-theme-secondary">Auto-Compact</p>
<p class="text-xs text-theme-muted">Automatically summarize older messages when context usage is high</p>
</div>
<button
type="button"
onclick={() => settingsState.toggleAutoCompact()}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.autoCompactEnabled ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={settingsState.autoCompactEnabled}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.autoCompactEnabled ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
{#if settingsState.autoCompactEnabled}
<!-- Threshold Slider -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="compact-threshold" class="text-sm font-medium text-theme-secondary">Context Threshold</label>
<span class="text-sm text-theme-muted">{settingsState.autoCompactThreshold}%</span>
</div>
<p class="text-xs text-theme-muted mb-2">Trigger compaction when context usage exceeds this percentage</p>
<input
id="compact-threshold"
type="range"
min={AUTO_COMPACT_RANGES.threshold.min}
max={AUTO_COMPACT_RANGES.threshold.max}
step={AUTO_COMPACT_RANGES.threshold.step}
value={settingsState.autoCompactThreshold}
oninput={(e) => settingsState.updateAutoCompactThreshold(parseInt(e.currentTarget.value))}
class="w-full accent-emerald-500"
/>
<div class="flex justify-between text-xs text-theme-muted mt-1">
<span>{AUTO_COMPACT_RANGES.threshold.min}%</span>
<span>{AUTO_COMPACT_RANGES.threshold.max}%</span>
</div>
</div>
<!-- Preserve Count -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="preserve-count" class="text-sm font-medium text-theme-secondary">Messages to Preserve</label>
<span class="text-sm text-theme-muted">{settingsState.autoCompactPreserveCount}</span>
</div>
<p class="text-xs text-theme-muted mb-2">Number of recent messages to keep intact (not summarized)</p>
<input
id="preserve-count"
type="range"
min={AUTO_COMPACT_RANGES.preserveCount.min}
max={AUTO_COMPACT_RANGES.preserveCount.max}
step={AUTO_COMPACT_RANGES.preserveCount.step}
value={settingsState.autoCompactPreserveCount}
oninput={(e) => settingsState.updateAutoCompactPreserveCount(parseInt(e.currentTarget.value))}
class="w-full accent-emerald-500"
/>
<div class="flex justify-between text-xs text-theme-muted mt-1">
<span>{AUTO_COMPACT_RANGES.preserveCount.min}</span>
<span>{AUTO_COMPACT_RANGES.preserveCount.max}</span>
</div>
</div>
{:else}
<p class="text-sm text-theme-muted py-2">
Enable auto-compact to automatically manage context usage. When enabled, older messages
will be summarized when context usage exceeds your threshold.
</p>
{/if}
</div>
</section>
<!-- Keyboard Shortcuts Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Keyboard Shortcuts
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">New Chat</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+N</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Search</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+K</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Toggle Sidebar</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+B</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Send Message</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Enter</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">New Line</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Shift+Enter</kbd>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
About
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="flex items-center gap-4">
<div class="rounded-lg bg-theme-tertiary p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
</div>
<div>
<h3 class="font-semibold text-theme-primary">Vessel</h3>
<p class="text-sm text-theme-muted">
A modern interface for local AI with chat, tools, and memory management.
</p>
</div>
</div>
</div>
</section>
</div>
</div>