Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1063bec248 | |||
| cf4981f3b2 | |||
| 7cc0df2c78 |
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// Version is set at build time via -ldflags, or defaults to dev
|
||||
var Version = "0.4.8"
|
||||
var Version = "0.4.9"
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
|
||||
32
frontend/package-lock.json
generated
32
frontend/package-lock.json
generated
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vessel",
|
||||
"version": "0.4.8",
|
||||
"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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
314
frontend/src/lib/components/chat/VirtualMessageList.svelte
Normal file
314
frontend/src/lib/components/chat/VirtualMessageList.svelte
Normal 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>
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
381
frontend/src/routes/settings/+page.svelte
Normal file
381
frontend/src/routes/settings/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user