feat: add context management, summarization, and UI improvements
Context Window Management: - Add ContextFullModal with recovery options (summarize, new chat, dismiss) - Show toast notifications at 85% and 95% context thresholds - Block sending when context exceeds 100% until user takes action Conversation Summarization: - Add isSummarized/isSummary flags to Message type - Implement markAsSummarized() and insertSummaryMessage() in ChatState - Add messagesForContext derived state (excludes summarized, includes summaries) - Complete handleSummarize flow with LLM summary generation - Add amber-styled summary message UI with archive icon Auto-scroll Fixes: - Fix Svelte 5 reactivity issues by using plain variables instead of $state - Add continuous scroll during streaming via streamBuffer tracking - Properly handle user scroll override (re-enable at bottom) Drag & Drop Improvements: - Add full-screen drag overlay with document-level event listeners - Use dragCounter pattern for reliable nested element detection - Add hideDropZone prop to FileUpload/ImageUpload components Additional Features: - Add SystemPromptSelector for per-conversation prompts - Add SearchModal for full-text message search - Add ShortcutsModal for keyboard shortcuts help - Add theme toggle to TopNav 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,14 @@
|
||||
/**
|
||||
* ChatInput - Message input area with auto-growing textarea
|
||||
* Handles send/stop actions, keyboard shortcuts, and file uploads
|
||||
* Drag overlay appears when files are dragged over the input area
|
||||
*/
|
||||
|
||||
import { modelsState } from '$lib/stores';
|
||||
import type { FileAttachment } from '$lib/types/attachment.js';
|
||||
import { formatAttachmentsForMessage } from '$lib/utils/file-processor.js';
|
||||
import { formatAttachmentsForMessage, processFile } from '$lib/utils/file-processor.js';
|
||||
import { isImageMimeType } from '$lib/types/attachment.js';
|
||||
import { estimateMessageTokens, formatTokenCount } from '$lib/memory/tokenizer';
|
||||
import FileUpload from './FileUpload.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -35,6 +38,10 @@
|
||||
// File attachment state (text/PDF for all models)
|
||||
let pendingAttachments = $state<FileAttachment[]>([]);
|
||||
|
||||
// Drag overlay state
|
||||
let isDragOver = $state(false);
|
||||
let dragCounter = 0; // Track enter/leave for nested elements
|
||||
|
||||
// Derived state
|
||||
const hasContent = $derived(
|
||||
inputValue.trim().length > 0 || pendingImages.length > 0 || pendingAttachments.length > 0
|
||||
@@ -45,6 +52,12 @@
|
||||
// Vision model detection
|
||||
const isVisionModel = $derived(modelsState.selectedSupportsVision);
|
||||
|
||||
// Token estimation for current input
|
||||
const tokenEstimate = $derived(
|
||||
estimateMessageTokens(inputValue, pendingImages.length > 0 ? pendingImages : undefined)
|
||||
);
|
||||
const showTokenCount = $derived(inputValue.length > 0 || pendingImages.length > 0);
|
||||
|
||||
/**
|
||||
* Auto-resize textarea based on content
|
||||
*/
|
||||
@@ -144,9 +157,132 @@
|
||||
$effect(() => {
|
||||
focusInput();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Drag & Drop - Document-level listeners for reliable detection
|
||||
// =========================================================================
|
||||
|
||||
// Set up document-level drag listeners for reliable detection
|
||||
$effect(() => {
|
||||
if (disabled) return;
|
||||
|
||||
function onDragEnter(event: DragEvent): void {
|
||||
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||
event.preventDefault();
|
||||
dragCounter++;
|
||||
isDragOver = true;
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
isDragOver = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent): void {
|
||||
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
isDragOver = false;
|
||||
dragCounter = 0;
|
||||
|
||||
if (!event.dataTransfer?.files.length) return;
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
processDroppedFiles(files);
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', onDragEnter);
|
||||
document.addEventListener('dragleave', onDragLeave);
|
||||
document.addEventListener('dragover', onDragOver);
|
||||
document.addEventListener('drop', onDrop);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', onDragEnter);
|
||||
document.removeEventListener('dragleave', onDragLeave);
|
||||
document.removeEventListener('dragover', onDragOver);
|
||||
document.removeEventListener('drop', onDrop);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Process dropped files - images go to pendingImages, others to attachments
|
||||
*/
|
||||
async function processDroppedFiles(files: File[]): Promise<void> {
|
||||
const imageFiles: File[] = [];
|
||||
const otherFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (isImageMimeType(file.type)) {
|
||||
imageFiles.push(file);
|
||||
} else {
|
||||
otherFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Process images if vision is supported
|
||||
if (imageFiles.length > 0 && isVisionModel) {
|
||||
const { processImageForOllama, isValidImageType } = await import('$lib/ollama/image-processor');
|
||||
const validImages = imageFiles.filter(isValidImageType);
|
||||
const maxImages = 4;
|
||||
const remainingSlots = maxImages - pendingImages.length;
|
||||
const toProcess = validImages.slice(0, remainingSlots);
|
||||
|
||||
for (const file of toProcess) {
|
||||
try {
|
||||
const processed = await processImageForOllama(file);
|
||||
pendingImages = [...pendingImages, processed.base64];
|
||||
} catch (err) {
|
||||
console.error('Failed to process image:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process other files
|
||||
for (const file of otherFiles) {
|
||||
const result = await processFile(file);
|
||||
if (result.success) {
|
||||
pendingAttachments = [...pendingAttachments, result.attachment];
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Full-screen drag overlay - shown when dragging files anywhere on the page -->
|
||||
{#if isDragOver}
|
||||
<div class="pointer-events-none fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/80 backdrop-blur-sm">
|
||||
<div class="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-violet-500 bg-slate-800/90 p-8 text-violet-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-12 w-12">
|
||||
<path fill-rule="evenodd" d="M10.5 3.75a6 6 0 0 0-5.98 6.496A5.25 5.25 0 0 0 6.75 20.25H18a4.5 4.5 0 0 0 2.206-8.423 3.75 3.75 0 0 0-4.133-4.303A6.001 6.001 0 0 0 10.5 3.75Zm2.03 5.47a.75.75 0 0 0-1.06 0l-3 3a.75.75 0 1 0 1.06 1.06l1.72-1.72v4.19a.75.75 0 0 0 1.5 0v-4.19l1.72 1.72a.75.75 0 1 0 1.06-1.06l-3-3Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-lg font-medium">
|
||||
{#if isVisionModel}
|
||||
Drop images or files
|
||||
{:else}
|
||||
Drop files here
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-sm text-slate-400">
|
||||
{#if isVisionModel}
|
||||
Images, text files, and PDFs supported
|
||||
{:else}
|
||||
Text files and PDFs supported
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative space-y-2">
|
||||
|
||||
<!-- File upload area (images for vision models, text/PDFs for all) -->
|
||||
<FileUpload
|
||||
images={pendingImages}
|
||||
@@ -155,6 +291,7 @@
|
||||
onAttachmentsChange={handleAttachmentsChange}
|
||||
supportsVision={isVisionModel}
|
||||
{disabled}
|
||||
hideDropZone={true}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -211,6 +348,7 @@
|
||||
rows="1"
|
||||
class="max-h-[200px] min-h-[40px] flex-1 resize-none bg-transparent px-1 py-1.5 text-slate-100 placeholder-slate-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Message input"
|
||||
data-chat-input
|
||||
></textarea>
|
||||
|
||||
<!-- Action buttons -->
|
||||
@@ -269,5 +407,11 @@
|
||||
<span class="mx-1 text-slate-700">+</span>
|
||||
{/if}
|
||||
<span class="text-slate-500">files supported</span>
|
||||
{#if showTokenCount}
|
||||
<span class="mx-1.5 text-slate-700">·</span>
|
||||
<span class="text-slate-500" title="{tokenEstimate.textTokens} text + {tokenEstimate.imageTokens} image tokens">
|
||||
~{formatTokenCount(tokenEstimate.totalTokens)} tokens
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,10 @@
|
||||
import ChatInput from './ChatInput.svelte';
|
||||
import EmptyState from './EmptyState.svelte';
|
||||
import ContextUsageBar from './ContextUsageBar.svelte';
|
||||
import ContextFullModal from './ContextFullModal.svelte';
|
||||
import SummaryBanner from './SummaryBanner.svelte';
|
||||
import StreamingStats from './StreamingStats.svelte';
|
||||
import SystemPromptSelector from './SystemPromptSelector.svelte';
|
||||
import ModelParametersPanel from '$lib/components/settings/ModelParametersPanel.svelte';
|
||||
import { settingsState } from '$lib/stores/settings.svelte';
|
||||
|
||||
@@ -58,6 +60,10 @@
|
||||
// Summarization state
|
||||
let isSummarizing = $state(false);
|
||||
|
||||
// Context full modal state
|
||||
let showContextFullModal = $state(false);
|
||||
let pendingMessage: { content: string; images?: string[] } | null = $state(null);
|
||||
|
||||
// Tool execution state
|
||||
let isExecutingTools = $state(false);
|
||||
|
||||
@@ -77,6 +83,35 @@
|
||||
checkKnowledgeBase();
|
||||
});
|
||||
|
||||
// Track previous context state for threshold crossing detection
|
||||
let previousContextState: 'normal' | 'warning' | 'critical' | 'full' = 'normal';
|
||||
|
||||
// Context warning toasts - show once per threshold crossing
|
||||
$effect(() => {
|
||||
const percentage = contextManager.contextUsage.percentage;
|
||||
let currentState: 'normal' | 'warning' | 'critical' | 'full' = 'normal';
|
||||
|
||||
if (percentage >= 100) {
|
||||
currentState = 'full';
|
||||
} else if (percentage >= 95) {
|
||||
currentState = 'critical';
|
||||
} else if (percentage >= 85) {
|
||||
currentState = 'warning';
|
||||
}
|
||||
|
||||
// Only show toast when crossing INTO a worse state
|
||||
if (currentState !== previousContextState) {
|
||||
if (currentState === 'warning' && previousContextState === 'normal') {
|
||||
toastState.warning('Context is 85% full. Consider starting a new chat soon.');
|
||||
} else if (currentState === 'critical' && previousContextState !== 'full') {
|
||||
toastState.warning('Context almost full (95%). Summarize or start a new chat.');
|
||||
} else if (currentState === 'full') {
|
||||
// Full state is handled by the modal, no toast needed
|
||||
}
|
||||
previousContextState = currentState;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if knowledge base has any documents
|
||||
*/
|
||||
@@ -165,10 +200,10 @@
|
||||
|
||||
/**
|
||||
* Convert chat state messages to Ollama API format
|
||||
* Uses allMessages to include hidden tool result messages
|
||||
* Uses messagesForContext to exclude summarized originals but include summaries
|
||||
*/
|
||||
function getMessagesForApi(): OllamaMessage[] {
|
||||
return chatState.allMessages.map((node) => ({
|
||||
return chatState.messagesForContext.map((node) => ({
|
||||
role: node.message.role as OllamaMessage['role'],
|
||||
content: node.message.content,
|
||||
images: node.message.images
|
||||
@@ -186,6 +221,7 @@
|
||||
const { toSummarize, toKeep } = selectMessagesForSummarization(messages, 0);
|
||||
|
||||
if (toSummarize.length === 0) {
|
||||
toastState.warning('No messages available to summarize');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,24 +230,88 @@
|
||||
try {
|
||||
// Generate summary using the LLM
|
||||
const summary = await generateSummary(toSummarize, selectedModel);
|
||||
const formattedSummary = formatSummaryAsContext(summary);
|
||||
|
||||
// Calculate savings
|
||||
const savedTokens = calculateTokenSavings(toSummarize, formattedSummary);
|
||||
// Calculate savings for logging
|
||||
const savedTokens = calculateTokenSavings(toSummarize, summary);
|
||||
|
||||
// TODO: Implement message replacement in chat state
|
||||
// This requires adding a method to ChatState to replace messages
|
||||
// with a summary node
|
||||
// Mark original messages as summarized (they'll be hidden from UI and context)
|
||||
const messageIdsToSummarize = toSummarize.map((node) => node.id);
|
||||
chatState.markAsSummarized(messageIdsToSummarize);
|
||||
|
||||
// Insert the summary message at the beginning (after any system messages)
|
||||
chatState.insertSummaryMessage(summary);
|
||||
|
||||
// Force context recalculation with updated message list
|
||||
contextManager.updateMessages(chatState.visibleMessages, true);
|
||||
|
||||
// Show success notification
|
||||
toastState.success(
|
||||
`Summarized ${toSummarize.length} messages, saved ~${Math.round(savedTokens / 100) * 100} tokens`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Summarization failed:', error);
|
||||
toastState.error('Summarization failed. Please try again.');
|
||||
} finally {
|
||||
isSummarizing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Context Full Modal Handlers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Send a message and stream the response (with tool support)
|
||||
* Handle "Summarize & Continue" from context full modal
|
||||
*/
|
||||
async function handleContextFullSummarize(): Promise<void> {
|
||||
showContextFullModal = false;
|
||||
await handleSummarize();
|
||||
|
||||
// After summarization, try to send the pending message
|
||||
if (pendingMessage && contextManager.contextUsage.percentage < 100) {
|
||||
const { content, images } = pendingMessage;
|
||||
pendingMessage = null;
|
||||
await handleSendMessage(content, images);
|
||||
} else if (pendingMessage) {
|
||||
// Still full after summarization - show toast
|
||||
toastState.warning('Context still full after summarization. Try starting a new chat.');
|
||||
pendingMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Start New Chat" from context full modal
|
||||
*/
|
||||
function handleContextFullNewChat(): void {
|
||||
showContextFullModal = false;
|
||||
pendingMessage = null;
|
||||
chatState.reset();
|
||||
contextManager.reset();
|
||||
toastState.info('Started new chat. Previous conversation was saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Continue Anyway" from context full modal
|
||||
*/
|
||||
async function handleContextFullDismiss(): Promise<void> {
|
||||
showContextFullModal = false;
|
||||
|
||||
// Try to send the message anyway (may fail or get truncated)
|
||||
if (pendingMessage) {
|
||||
const { content, images } = pendingMessage;
|
||||
pendingMessage = null;
|
||||
// Bypass the context check by calling the inner logic directly
|
||||
await sendMessageInternal(content, images);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if summarization is possible (enough messages)
|
||||
*/
|
||||
const canSummarizeConversation = $derived(chatState.visibleMessages.length >= 6);
|
||||
|
||||
/**
|
||||
* Send a message - checks context and may show modal
|
||||
*/
|
||||
async function handleSendMessage(content: string, images?: string[]): Promise<void> {
|
||||
const selectedModel = modelsState.selectedId;
|
||||
@@ -221,6 +321,24 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if context is full (100%+)
|
||||
if (contextManager.contextUsage.percentage >= 100) {
|
||||
// Store pending message and show modal
|
||||
pendingMessage = { content, images };
|
||||
showContextFullModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessageInternal(content, images);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Send message and stream response (bypasses context check)
|
||||
*/
|
||||
async function sendMessageInternal(content: string, images?: string[]): Promise<void> {
|
||||
const selectedModel = modelsState.selectedId;
|
||||
if (!selectedModel) return;
|
||||
|
||||
// In 'new' mode with no messages yet, create conversation first
|
||||
if (mode === 'new' && !hasMessages && onFirstMessage) {
|
||||
await onFirstMessage(content, images);
|
||||
@@ -289,11 +407,24 @@
|
||||
// Build system prompt from active prompt + thinking + RAG context
|
||||
const systemParts: string[] = [];
|
||||
|
||||
// Wait for prompts to be loaded, then add system prompt if active
|
||||
// Wait for prompts to be loaded
|
||||
await promptsState.ready();
|
||||
const activePrompt = promptsState.activePrompt;
|
||||
if (activePrompt) {
|
||||
systemParts.push(activePrompt.content);
|
||||
|
||||
// Priority: per-conversation prompt > global active prompt > none
|
||||
let promptContent: string | null = null;
|
||||
if (conversation?.systemPromptId) {
|
||||
// Use per-conversation prompt
|
||||
const conversationPrompt = promptsState.get(conversation.systemPromptId);
|
||||
if (conversationPrompt) {
|
||||
promptContent = conversationPrompt.content;
|
||||
}
|
||||
} else if (promptsState.activePrompt) {
|
||||
// Fall back to global active prompt
|
||||
promptContent = promptsState.activePrompt.content;
|
||||
}
|
||||
|
||||
if (promptContent) {
|
||||
systemParts.push(promptContent);
|
||||
}
|
||||
|
||||
// RAG: Retrieve relevant context for the last user message
|
||||
@@ -703,26 +834,36 @@
|
||||
<StreamingStats />
|
||||
</div>
|
||||
|
||||
<!-- Chat options bar (settings toggle + thinking mode toggle) -->
|
||||
<!-- Chat options bar (settings toggle + system prompt + thinking mode toggle) -->
|
||||
<div class="flex items-center justify-between gap-3 px-4 pt-3">
|
||||
<!-- Left side: Settings gear -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => settingsState.togglePanel()}
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
class:bg-slate-800={settingsState.isPanelOpen}
|
||||
class:text-sky-400={settingsState.isPanelOpen || settingsState.useCustomParameters}
|
||||
aria-label="Toggle model parameters"
|
||||
aria-expanded={settingsState.isPanelOpen}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{#if settingsState.useCustomParameters}
|
||||
<span class="text-[10px]">Custom</span>
|
||||
<!-- Left side: Settings gear + System prompt selector -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => settingsState.togglePanel()}
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
class:bg-slate-800={settingsState.isPanelOpen}
|
||||
class:text-sky-400={settingsState.isPanelOpen || settingsState.useCustomParameters}
|
||||
aria-label="Toggle model parameters"
|
||||
aria-expanded={settingsState.isPanelOpen}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{#if settingsState.useCustomParameters}
|
||||
<span class="text-[10px]">Custom</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- System prompt selector (only in conversation mode) -->
|
||||
{#if mode === 'conversation' && conversation}
|
||||
<SystemPromptSelector
|
||||
conversationId={conversation.id}
|
||||
currentPromptId={conversation.systemPromptId}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Thinking mode toggle -->
|
||||
{#if supportsThinking}
|
||||
@@ -762,3 +903,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context full modal -->
|
||||
<ContextFullModal
|
||||
isOpen={showContextFullModal}
|
||||
onSummarize={handleContextFullSummarize}
|
||||
onNewChat={handleContextFullNewChat}
|
||||
onDismiss={handleContextFullDismiss}
|
||||
{isSummarizing}
|
||||
canSummarize={canSummarizeConversation}
|
||||
/>
|
||||
|
||||
141
frontend/src/lib/components/chat/ContextFullModal.svelte
Normal file
141
frontend/src/lib/components/chat/ContextFullModal.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ContextFullModal - Shown when context window is exhausted
|
||||
* Offers recovery options: summarize, new chat, or dismiss
|
||||
*/
|
||||
|
||||
import { contextManager } from '$lib/memory';
|
||||
import { formatTokenCount } from '$lib/memory/tokenizer';
|
||||
import { formatContextSize } from '$lib/memory/model-limits';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onSummarize: () => void;
|
||||
onNewChat: () => void;
|
||||
onDismiss: () => void;
|
||||
isSummarizing?: boolean;
|
||||
canSummarize?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
onSummarize,
|
||||
onNewChat,
|
||||
onDismiss,
|
||||
isSummarizing = false,
|
||||
canSummarize = true
|
||||
}: Props = $props();
|
||||
|
||||
// Context usage info
|
||||
const usage = $derived(contextManager.contextUsage);
|
||||
const overflowAmount = $derived(Math.max(0, usage.usedTokens - usage.maxTokens));
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<!-- Modal -->
|
||||
<div class="mx-4 w-full max-w-md rounded-2xl border border-red-500/30 bg-slate-900 p-6 shadow-2xl">
|
||||
<!-- Header with warning icon -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-500/20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-red-400" 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>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Context Window Full</h2>
|
||||
<p class="text-sm text-slate-400">
|
||||
{formatTokenCount(usage.usedTokens)} / {formatContextSize(usage.maxTokens)} tokens used
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation -->
|
||||
<div class="mb-6 rounded-lg bg-slate-800/50 p-4 text-sm text-slate-300">
|
||||
<p class="mb-2">
|
||||
The conversation has exceeded the model's context window by
|
||||
<span class="font-medium text-red-400">{formatTokenCount(overflowAmount)} tokens</span>.
|
||||
</p>
|
||||
<p class="text-slate-400">
|
||||
The model cannot process more text until space is freed. Choose how to proceed:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-3">
|
||||
<!-- Summarize option -->
|
||||
{#if canSummarize}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onSummarize}
|
||||
disabled={isSummarizing}
|
||||
class="flex w-full items-center gap-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4 text-left transition-colors hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-500/20">
|
||||
{#if isSummarizing}
|
||||
<svg class="h-5 w-5 animate-spin text-emerald-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-emerald-300">
|
||||
{isSummarizing ? 'Summarizing...' : 'Summarize & Continue'}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400">
|
||||
Compress older messages into a summary to free space
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-400">
|
||||
Recommended
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- New chat option -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onNewChat}
|
||||
class="flex w-full items-center gap-3 rounded-xl border border-slate-600/50 bg-slate-800/50 p-4 text-left transition-colors hover:bg-slate-700/50"
|
||||
>
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-slate-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-slate-200">Start New Chat</div>
|
||||
<div class="text-xs text-slate-400">
|
||||
Begin a fresh conversation (current chat is saved)
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Dismiss option (risky) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDismiss}
|
||||
class="flex w-full items-center gap-3 rounded-xl border border-slate-700/50 p-4 text-left transition-colors hover:bg-slate-800/50"
|
||||
>
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-slate-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-slate-400">Continue Anyway</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
Try to send (may result in errors or truncated responses)
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -22,6 +22,8 @@
|
||||
supportsVision?: boolean;
|
||||
/** Whether upload is disabled */
|
||||
disabled?: boolean;
|
||||
/** Hide the drop zone (when drag overlay is handled by parent) */
|
||||
hideDropZone?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -30,7 +32,8 @@
|
||||
attachments,
|
||||
onAttachmentsChange,
|
||||
supportsVision = false,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
hideDropZone = false
|
||||
}: Props = $props();
|
||||
|
||||
// Processing state
|
||||
@@ -112,7 +115,9 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle paste events for file attachments
|
||||
* Handle paste events for file attachments and images
|
||||
* Images are forwarded to ImageUpload when vision is supported,
|
||||
* otherwise shows a helpful message
|
||||
*/
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
if (disabled) return;
|
||||
@@ -121,22 +126,101 @@
|
||||
if (!items) return;
|
||||
|
||||
const files: File[] = [];
|
||||
const imageFiles: File[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
// Handle non-image files (images handled by ImageUpload)
|
||||
if (!item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
|
||||
if (item.type.startsWith('image/')) {
|
||||
imageFiles.push(file);
|
||||
} else {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-image files
|
||||
if (files.length > 0) {
|
||||
// Don't prevent default if we have no files to process
|
||||
// (let ImageUpload handle images)
|
||||
processFiles(files);
|
||||
}
|
||||
|
||||
// Handle image files
|
||||
if (imageFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
if (supportsVision) {
|
||||
// Forward to ImageUpload by triggering file processing there
|
||||
// ImageUpload has its own paste handler, but we handle it here too
|
||||
// to ensure consistent behavior
|
||||
processImageFiles(imageFiles);
|
||||
} else {
|
||||
// Show message that images require vision model
|
||||
errorMessage = 'Images can only be used with vision-capable models (e.g., llava, bakllava)';
|
||||
setTimeout(() => {
|
||||
errorMessage = null;
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process image files and add to images array
|
||||
*/
|
||||
async function processImageFiles(files: File[]) {
|
||||
// Import the processor dynamically to keep bundle small when not needed
|
||||
const { processImageForOllama, isValidImageType, ImageProcessingError } = await import(
|
||||
'$lib/ollama/image-processor'
|
||||
);
|
||||
|
||||
const validFiles = files.filter(isValidImageType);
|
||||
if (validFiles.length === 0) {
|
||||
errorMessage = 'No valid image files. Supported: JPEG, PNG, GIF, WebP';
|
||||
setTimeout(() => {
|
||||
errorMessage = null;
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit to 4 images max
|
||||
const maxImages = 4;
|
||||
const remainingSlots = maxImages - images.length;
|
||||
const filesToProcess = validFiles.slice(0, remainingSlots);
|
||||
|
||||
if (filesToProcess.length === 0) {
|
||||
errorMessage = `Maximum ${maxImages} images allowed`;
|
||||
setTimeout(() => {
|
||||
errorMessage = null;
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
errorMessage = null;
|
||||
|
||||
try {
|
||||
const newImages: string[] = [];
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
try {
|
||||
const processed = await processImageForOllama(file);
|
||||
newImages.push(processed.base64);
|
||||
} catch (err) {
|
||||
if (err instanceof ImageProcessingError) {
|
||||
console.error(`Failed to process ${file.name}:`, err.message);
|
||||
errorMessage = err.message;
|
||||
} else {
|
||||
console.error(`Failed to process ${file.name}:`, err);
|
||||
errorMessage = `Failed to process ${file.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
onImagesChange([...images, ...newImages]);
|
||||
}
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up paste listener
|
||||
@@ -153,7 +237,7 @@
|
||||
<div class="space-y-3">
|
||||
<!-- Image upload section (only for vision models) -->
|
||||
{#if supportsVision}
|
||||
<ImageUpload {images} {onImagesChange} {disabled} />
|
||||
<ImageUpload {images} {onImagesChange} {disabled} {hideDropZone} />
|
||||
{/if}
|
||||
|
||||
<!-- File attachments preview -->
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ImageUpload - Drag and drop image upload with clipboard support
|
||||
* ImageUpload - Drag and drop image upload for vision models
|
||||
* Supports multiple images, shows thumbnails, and handles removal
|
||||
* Note: Clipboard paste is handled by FileUpload for consistent behavior
|
||||
*/
|
||||
|
||||
import { processImageForOllama, isValidImageType, ImageProcessingError } from '$lib/ollama/image-processor';
|
||||
@@ -16,13 +17,16 @@
|
||||
disabled?: boolean;
|
||||
/** Maximum number of images allowed */
|
||||
maxImages?: number;
|
||||
/** Hide the drop zone (when drag is handled by parent) */
|
||||
hideDropZone?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
images,
|
||||
onImagesChange,
|
||||
disabled = false,
|
||||
maxImages = 4
|
||||
maxImages = 4,
|
||||
hideDropZone = false
|
||||
}: Props = $props();
|
||||
|
||||
/** Drag over state for visual feedback */
|
||||
@@ -144,32 +148,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle paste from clipboard
|
||||
*/
|
||||
function handlePaste(event: ClipboardEvent): void {
|
||||
if (disabled) return;
|
||||
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFiles(imageFiles);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an image at the given index
|
||||
*/
|
||||
@@ -188,17 +166,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Set up paste listener
|
||||
$effect(() => {
|
||||
if (!disabled) {
|
||||
document.addEventListener('paste', handlePaste);
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}
|
||||
});
|
||||
// Note: Paste handling is done in FileUpload.svelte to ensure
|
||||
// consistent behavior whether or not this component is rendered
|
||||
</script>
|
||||
|
||||
<!-- Only show image previews when hideDropZone is true (parent handles drag) -->
|
||||
{#if hideDropZone}
|
||||
{#if images.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each images as image, index (index)}
|
||||
<ImagePreview
|
||||
src={image}
|
||||
onRemove={() => removeImage(index)}
|
||||
alt={`Uploaded image ${index + 1}`}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<!-- Image previews -->
|
||||
{#if images.length > 0}
|
||||
@@ -287,7 +272,7 @@
|
||||
{#if isDragOver}
|
||||
Drop images here
|
||||
{:else}
|
||||
Drag & drop, click, or paste images
|
||||
Drag & drop or click to add images
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
@@ -308,3 +293,4 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -40,9 +40,13 @@
|
||||
|
||||
const isUser = $derived(node.message.role === 'user');
|
||||
const isAssistant = $derived(node.message.role === 'assistant');
|
||||
const isSystem = $derived(node.message.role === 'system');
|
||||
const hasContent = $derived(node.message.content.length > 0);
|
||||
const hasToolCalls = $derived(node.message.toolCalls && node.message.toolCalls.length > 0);
|
||||
|
||||
// Detect summary messages (compressed conversation history)
|
||||
const isSummaryMessage = $derived(node.message.isSummary === true);
|
||||
|
||||
// Detect tool result messages (sent as user role but should be hidden or styled differently)
|
||||
const isToolResultMessage = $derived(
|
||||
isUser && (
|
||||
@@ -104,6 +108,34 @@
|
||||
<!-- Hide tool result messages - they're internal API messages -->
|
||||
{#if isToolResultMessage}
|
||||
<!-- Tool results are handled in the assistant message display -->
|
||||
{:else if isSummaryMessage}
|
||||
<!-- Summary message - special compact styling -->
|
||||
<article
|
||||
class="mb-4 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4"
|
||||
aria-label="Conversation summary"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Archive/compress icon -->
|
||||
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||
<path d="M3.75 3A1.75 1.75 0 002 4.75v3.26a3.235 3.235 0 011.75-.51h12.5c.644 0 1.245.188 1.75.51V6.75A1.75 1.75 0 0016.25 5h-4.836a.25.25 0 01-.177-.073L9.823 3.513A1.75 1.75 0 008.586 3H3.75zM3.75 9A1.75 1.75 0 002 10.75v4.5c0 .966.784 1.75 1.75 1.75h12.5A1.75 1.75 0 0018 15.25v-4.5A1.75 1.75 0 0016.25 9H3.75z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-amber-400">Conversation Summary</span>
|
||||
<span class="text-xs text-slate-500">Earlier messages compressed</span>
|
||||
</div>
|
||||
<div class="prose prose-sm prose-invert max-w-none text-slate-300">
|
||||
<MessageContent
|
||||
content={node.message.content.replace('[Previous conversation summary]\n\n', '')}
|
||||
{isStreaming}
|
||||
{showThinking}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
<article
|
||||
class="group mb-6 flex gap-4"
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
let userScrolledAway = $state(false);
|
||||
|
||||
// Track previous streaming state to detect when streaming ends
|
||||
let wasStreaming = $state(false);
|
||||
// Note: Using plain variables (not $state) to avoid re-triggering effects
|
||||
let wasStreaming = false;
|
||||
|
||||
// Threshold for "near bottom" detection
|
||||
const SCROLL_THRESHOLD = 100;
|
||||
@@ -51,25 +52,25 @@
|
||||
|
||||
/**
|
||||
* Scroll to bottom smoothly (for button click)
|
||||
* Note: userScrolledAway is updated naturally by handleScroll when we reach bottom
|
||||
*/
|
||||
function scrollToBottom(): void {
|
||||
if (anchorElement) {
|
||||
anchorElement.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
userScrolledAway = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to bottom instantly (for auto-scroll)
|
||||
* Note: userScrolledAway is updated naturally by handleScroll when we reach bottom
|
||||
*/
|
||||
function scrollToBottomInstant(): void {
|
||||
if (anchorElement) {
|
||||
anchorElement.scrollIntoView({ block: 'end' });
|
||||
userScrolledAway = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll when streaming starts (if user hasn't scrolled away)
|
||||
// Auto-scroll when streaming state changes
|
||||
$effect(() => {
|
||||
const isStreaming = chatState.isStreaming;
|
||||
|
||||
@@ -95,8 +96,22 @@
|
||||
wasStreaming = isStreaming;
|
||||
});
|
||||
|
||||
// Continuous scroll during streaming as content grows
|
||||
$effect(() => {
|
||||
// Track stream buffer changes - when content grows during streaming, scroll
|
||||
const buffer = chatState.streamBuffer;
|
||||
const isStreaming = chatState.isStreaming;
|
||||
|
||||
if (isStreaming && buffer && !userScrolledAway) {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottomInstant();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll when new messages are added (user sends a message)
|
||||
let previousMessageCount = $state(0);
|
||||
// Note: Using plain variable to avoid creating a dependency that re-triggers the effect
|
||||
let previousMessageCount = 0;
|
||||
$effect(() => {
|
||||
const currentCount = chatState.visibleMessages.length;
|
||||
|
||||
|
||||
206
frontend/src/lib/components/chat/SystemPromptSelector.svelte
Normal file
206
frontend/src/lib/components/chat/SystemPromptSelector.svelte
Normal file
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SystemPromptSelector - Dropdown to select a system prompt for the current conversation
|
||||
* Allows per-conversation prompt assignment with quick preview
|
||||
*/
|
||||
import { promptsState, conversationsState, toastState } from '$lib/stores';
|
||||
import { updateSystemPrompt } from '$lib/storage';
|
||||
|
||||
interface Props {
|
||||
conversationId: string | null;
|
||||
currentPromptId?: string | null;
|
||||
}
|
||||
|
||||
let { conversationId, currentPromptId = null }: Props = $props();
|
||||
|
||||
// UI state
|
||||
let isOpen = $state(false);
|
||||
let dropdownElement: HTMLDivElement | null = $state(null);
|
||||
|
||||
// Available prompts from store
|
||||
const prompts = $derived(promptsState.prompts);
|
||||
|
||||
// Current prompt for this conversation
|
||||
const currentPrompt = $derived(
|
||||
currentPromptId ? prompts.find((p) => p.id === currentPromptId) : null
|
||||
);
|
||||
|
||||
// Display text for the button
|
||||
const buttonText = $derived(currentPrompt?.name ?? 'No system prompt');
|
||||
|
||||
/**
|
||||
* Toggle dropdown
|
||||
*/
|
||||
function toggleDropdown(): void {
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close dropdown
|
||||
*/
|
||||
function closeDropdown(): void {
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle prompt selection
|
||||
*/
|
||||
async function handleSelect(promptId: string | null): Promise<void> {
|
||||
if (!conversationId) return;
|
||||
|
||||
// Update in storage
|
||||
const result = await updateSystemPrompt(conversationId, promptId);
|
||||
if (result.success) {
|
||||
// Update in memory
|
||||
conversationsState.setSystemPrompt(conversationId, promptId);
|
||||
const promptName = promptId ? prompts.find((p) => p.id === promptId)?.name : null;
|
||||
toastState.success(promptName ? `Using "${promptName}"` : 'System prompt cleared');
|
||||
} else {
|
||||
toastState.error('Failed to update system prompt');
|
||||
}
|
||||
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click outside to close
|
||||
*/
|
||||
function handleClickOutside(event: MouseEvent): void {
|
||||
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle escape key
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
|
||||
|
||||
<div class="relative" bind:this={dropdownElement}>
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleDropdown}
|
||||
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors {currentPrompt
|
||||
? 'bg-violet-500/20 text-violet-300 hover:bg-violet-500/30'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'}"
|
||||
title={currentPrompt ? `System prompt: ${currentPrompt.name}` : 'Set system prompt'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-3.5 w-3.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="max-w-[120px] truncate">{buttonText}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-3.5 w-3.5 transition-transform {isOpen ? 'rotate-180' : ''}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="absolute left-0 top-full z-50 mt-1 w-64 rounded-lg border border-slate-700 bg-slate-800 py-1 shadow-xl"
|
||||
>
|
||||
<!-- No prompt option -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(null)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-slate-700 {!currentPromptId
|
||||
? 'bg-slate-700/50 text-slate-100'
|
||||
: 'text-slate-300'}"
|
||||
>
|
||||
<span class="flex-1">No system prompt</span>
|
||||
{#if !currentPromptId}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4 text-emerald-400"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if prompts.length > 0}
|
||||
<div class="my-1 border-t border-slate-700"></div>
|
||||
|
||||
<!-- Available prompts -->
|
||||
{#each prompts as prompt}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(prompt.id)}
|
||||
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-slate-700 {currentPromptId ===
|
||||
prompt.id
|
||||
? 'bg-slate-700/50'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex-1 text-sm font-medium {currentPromptId === prompt.id
|
||||
? 'text-slate-100'
|
||||
: 'text-slate-300'}"
|
||||
>
|
||||
{prompt.name}
|
||||
{#if prompt.isDefault}
|
||||
<span class="ml-1 text-xs text-emerald-400">(default)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if currentPromptId === prompt.id}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4 text-emerald-400"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{#if prompt.description}
|
||||
<span class="line-clamp-1 text-xs text-slate-500">{prompt.description}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-xs text-slate-500">
|
||||
No prompts available. <a href="/prompts" class="text-violet-400 hover:underline"
|
||||
>Create one</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -30,3 +30,11 @@ export { default as CodeBlock } from './CodeBlock.svelte';
|
||||
// Indicators and states
|
||||
export { default as StreamingIndicator } from './StreamingIndicator.svelte';
|
||||
export { default as EmptyState } from './EmptyState.svelte';
|
||||
|
||||
// Context management
|
||||
export { default as ContextUsageBar } from './ContextUsageBar.svelte';
|
||||
export { default as ContextFullModal } from './ContextFullModal.svelte';
|
||||
export { default as SummaryBanner } from './SummaryBanner.svelte';
|
||||
|
||||
// Prompt selection
|
||||
export { default as SystemPromptSelector } from './SystemPromptSelector.svelte';
|
||||
|
||||
@@ -167,8 +167,50 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Right section: Chat actions -->
|
||||
<!-- Right section: Theme toggle + Chat actions -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Theme toggle (always visible) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => uiState.toggleDarkMode()}
|
||||
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
aria-label={uiState.darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={uiState.darkMode ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
{#if uiState.darkMode}
|
||||
<!-- Sun icon - shown in dark mode to switch to light -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Moon icon - shown in light mode to switch to dark -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isInChat}
|
||||
<!-- Export button -->
|
||||
|
||||
360
frontend/src/lib/components/shared/SearchModal.svelte
Normal file
360
frontend/src/lib/components/shared/SearchModal.svelte
Normal file
@@ -0,0 +1,360 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SearchModal - Global search modal for conversations and messages
|
||||
* Supports searching both conversation titles and message content
|
||||
*/
|
||||
import { goto } from '$app/navigation';
|
||||
import { searchConversations, searchMessages, type MessageSearchResult } from '$lib/storage';
|
||||
import { conversationsState } from '$lib/stores';
|
||||
import type { Conversation } from '$lib/types/conversation';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose }: Props = $props();
|
||||
|
||||
// Search state
|
||||
let searchQuery = $state('');
|
||||
let activeTab = $state<'titles' | 'messages'>('titles');
|
||||
let isSearching = $state(false);
|
||||
|
||||
// Results
|
||||
let titleResults = $state<Conversation[]>([]);
|
||||
let messageResults = $state<MessageSearchResult[]>([]);
|
||||
|
||||
// Debounce timer
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Input element for focus
|
||||
let inputElement: HTMLInputElement | null = $state(null);
|
||||
|
||||
/**
|
||||
* Debounced search
|
||||
*/
|
||||
function handleSearch(): void {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
titleResults = [];
|
||||
messageResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
isSearching = true;
|
||||
|
||||
try {
|
||||
// Search both in parallel
|
||||
const [titlesResult, messagesResult] = await Promise.all([
|
||||
searchConversations(searchQuery),
|
||||
searchMessages(searchQuery, { limit: 30 })
|
||||
]);
|
||||
|
||||
if (titlesResult.success) {
|
||||
titleResults = titlesResult.data;
|
||||
}
|
||||
|
||||
if (messagesResult.success) {
|
||||
messageResults = messagesResult.data;
|
||||
}
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a conversation
|
||||
*/
|
||||
function navigateToConversation(conversationId: string): void {
|
||||
onClose();
|
||||
goto(`/chat/${conversationId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a snippet around the match
|
||||
*/
|
||||
function getSnippet(content: string, matchIndex: number, query: string): string {
|
||||
const snippetLength = 100;
|
||||
const start = Math.max(0, matchIndex - 40);
|
||||
const end = Math.min(content.length, matchIndex + query.length + 60);
|
||||
|
||||
let snippet = content.slice(start, end);
|
||||
|
||||
// Add ellipsis if truncated
|
||||
if (start > 0) snippet = '...' + snippet;
|
||||
if (end < content.length) snippet = snippet + '...';
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation title for a message result
|
||||
*/
|
||||
function getConversationTitle(conversationId: string): string {
|
||||
const conversation = conversationsState.find(conversationId);
|
||||
return conversation?.title ?? 'Unknown conversation';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle backdrop click
|
||||
*/
|
||||
function handleBackdropClick(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state and close
|
||||
*/
|
||||
function handleClose(): void {
|
||||
searchQuery = '';
|
||||
titleResults = [];
|
||||
messageResults = [];
|
||||
activeTab = 'titles';
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Focus input when modal opens
|
||||
$effect(() => {
|
||||
if (isOpen && inputElement) {
|
||||
setTimeout(() => inputElement?.focus(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear results when closing
|
||||
$effect(() => {
|
||||
if (!isOpen) {
|
||||
searchQuery = '';
|
||||
titleResults = [];
|
||||
messageResults = [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-start justify-center bg-black/60 pt-[15vh] backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="search-dialog-title"
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<div class="mx-4 w-full max-w-2xl rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
|
||||
<!-- Search input -->
|
||||
<div class="flex items-center gap-3 border-b border-slate-700 px-4 py-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-slate-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
type="text"
|
||||
placeholder="Search conversations and messages..."
|
||||
class="flex-1 bg-transparent text-slate-100 placeholder-slate-500 focus:outline-none"
|
||||
/>
|
||||
{#if isSearching}
|
||||
<svg class="h-5 w-5 animate-spin text-slate-400" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
searchQuery = '';
|
||||
titleResults = [];
|
||||
messageResults = [];
|
||||
inputElement?.focus();
|
||||
}}
|
||||
class="rounded p-1 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<kbd class="rounded bg-slate-800 px-2 py-0.5 text-xs text-slate-500">Esc</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'titles')}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'titles'
|
||||
? 'border-b-2 border-violet-500 text-violet-400'
|
||||
: 'text-slate-400 hover:text-slate-200'}"
|
||||
>
|
||||
Titles
|
||||
{#if titleResults.length > 0}
|
||||
<span class="ml-1.5 rounded-full bg-slate-800 px-1.5 py-0.5 text-xs"
|
||||
>{titleResults.length}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'messages')}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'messages'
|
||||
? 'border-b-2 border-violet-500 text-violet-400'
|
||||
: 'text-slate-400 hover:text-slate-200'}"
|
||||
>
|
||||
Messages
|
||||
{#if messageResults.length > 0}
|
||||
<span class="ml-1.5 rounded-full bg-slate-800 px-1.5 py-0.5 text-xs"
|
||||
>{messageResults.length}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="max-h-[50vh] overflow-y-auto">
|
||||
{#if !searchQuery.trim()}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-col items-center justify-center py-12 text-slate-500">
|
||||
<svg class="mb-3 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">Start typing to search...</p>
|
||||
</div>
|
||||
{:else if activeTab === 'titles'}
|
||||
{#if titleResults.length === 0 && !isSearching}
|
||||
<div class="py-8 text-center text-sm text-slate-500">
|
||||
No conversations found matching "{searchQuery}"
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-slate-800">
|
||||
{#each titleResults as result}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateToConversation(result.id)}
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-800"
|
||||
>
|
||||
<svg class="h-4 w-4 flex-shrink-0 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
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>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-200">
|
||||
{result.title}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
{result.messageCount} messages · {result.model}
|
||||
</p>
|
||||
</div>
|
||||
{#if result.isPinned}
|
||||
<svg class="h-4 w-4 text-emerald-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if messageResults.length === 0 && !isSearching}
|
||||
<div class="py-8 text-center text-sm text-slate-500">
|
||||
No messages found matching "{searchQuery}"
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-slate-800">
|
||||
{#each messageResults as result}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateToConversation(result.conversationId)}
|
||||
class="flex w-full flex-col gap-1 px-4 py-3 text-left transition-colors hover:bg-slate-800"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase {result.role ===
|
||||
'user'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-emerald-500/20 text-emerald-400'}"
|
||||
>
|
||||
{result.role}
|
||||
</span>
|
||||
<span class="truncate text-xs text-slate-500">
|
||||
{getConversationTitle(result.conversationId)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="line-clamp-2 text-sm text-slate-300">
|
||||
{getSnippet(result.content, result.matchIndex, searchQuery)}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer hint -->
|
||||
<div class="border-t border-slate-700 px-4 py-2">
|
||||
<p class="text-center text-xs text-slate-500">
|
||||
<kbd class="rounded bg-slate-800 px-1.5 py-0.5 font-mono">Enter</kbd> to select ·
|
||||
<kbd class="rounded bg-slate-800 px-1.5 py-0.5 font-mono">Tab</kbd> to switch tabs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
125
frontend/src/lib/components/shared/ShortcutsModal.svelte
Normal file
125
frontend/src/lib/components/shared/ShortcutsModal.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ShortcutsModal - Display available keyboard shortcuts
|
||||
*/
|
||||
import { keyboardShortcuts, formatShortcut, type Shortcut } from '$lib/utils/keyboard';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose }: Props = $props();
|
||||
|
||||
// Get all registered shortcuts
|
||||
const shortcuts = $derived(keyboardShortcuts.getShortcuts());
|
||||
|
||||
// Group shortcuts by category
|
||||
const groupedShortcuts = $derived.by(() => {
|
||||
const groups: Record<string, Shortcut[]> = {
|
||||
'Navigation': [],
|
||||
'Chat': [],
|
||||
'General': []
|
||||
};
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
if (['new-chat', 'search', 'toggle-sidenav'].includes(shortcut.id)) {
|
||||
groups['Navigation'].push(shortcut);
|
||||
} else if (['focus-input', 'send-message', 'stop-generation'].includes(shortcut.id)) {
|
||||
groups['Chat'].push(shortcut);
|
||||
} else {
|
||||
groups['General'].push(shortcut);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle backdrop click to close
|
||||
*/
|
||||
function handleBackdropClick(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle escape key to close
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-dialog-title"
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<div class="mx-4 w-full max-w-md rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-700 px-6 py-4">
|
||||
<h2 id="shortcuts-dialog-title" class="text-lg font-semibold text-slate-100">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-[60vh] overflow-y-auto px-6 py-4">
|
||||
{#each Object.entries(groupedShortcuts) as [group, items]}
|
||||
{#if items.length > 0}
|
||||
<div class="mb-4 last:mb-0">
|
||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
{group}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each items as shortcut}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-slate-300">{shortcut.description}</span>
|
||||
<kbd class="rounded bg-slate-800 px-2 py-1 font-mono text-xs text-slate-400">
|
||||
{formatShortcut(shortcut.key, shortcut.modifiers)}
|
||||
</kbd>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-slate-700 px-6 py-3">
|
||||
<p class="text-center text-xs text-slate-500">
|
||||
Press <kbd class="rounded bg-slate-800 px-1.5 py-0.5 font-mono">Shift+?</kbd> to toggle this panel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -11,3 +11,5 @@ 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';
|
||||
|
||||
@@ -22,8 +22,11 @@ Keep the summary brief but complete. Write in third person.
|
||||
Conversation:
|
||||
`;
|
||||
|
||||
/** Minimum messages to consider for summarization */
|
||||
const MIN_MESSAGES_FOR_SUMMARY = 6;
|
||||
/** Minimum messages required in the toSummarize array (after filtering) */
|
||||
const MIN_MESSAGES_TO_SUMMARIZE = 2;
|
||||
|
||||
/** Minimum total messages before showing summarization option */
|
||||
const MIN_TOTAL_MESSAGES_FOR_SUMMARY = 6;
|
||||
|
||||
/** How many recent messages to always preserve */
|
||||
const PRESERVE_RECENT_MESSAGES = 4;
|
||||
@@ -54,7 +57,7 @@ export async function generateSummary(
|
||||
messages: MessageNode[],
|
||||
model: string
|
||||
): Promise<string> {
|
||||
if (messages.length < MIN_MESSAGES_FOR_SUMMARY) {
|
||||
if (messages.length < MIN_MESSAGES_TO_SUMMARIZE) {
|
||||
throw new Error('Not enough messages to summarize');
|
||||
}
|
||||
|
||||
@@ -151,8 +154,15 @@ export function shouldSummarize(
|
||||
maxTokens: number,
|
||||
messageCount: number
|
||||
): boolean {
|
||||
// Don't summarize if too few messages
|
||||
if (messageCount < MIN_MESSAGES_FOR_SUMMARY) {
|
||||
// Don't show summarization if not enough total messages
|
||||
if (messageCount < MIN_TOTAL_MESSAGES_FOR_SUMMARY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there would actually be messages to summarize after filtering
|
||||
// (messageCount - PRESERVE_RECENT_MESSAGES = messages available for summarization)
|
||||
const summarizableCount = messageCount - PRESERVE_RECENT_MESSAGES;
|
||||
if (summarizableCount < MIN_MESSAGES_TO_SUMMARIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ function toDomainConversation(stored: StoredConversation): Conversation {
|
||||
updatedAt: new Date(stored.updatedAt),
|
||||
isPinned: stored.isPinned,
|
||||
isArchived: stored.isArchived,
|
||||
messageCount: stored.messageCount
|
||||
messageCount: stored.messageCount,
|
||||
systemPromptId: stored.systemPromptId ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,7 +43,8 @@ function toStoredConversation(
|
||||
updatedAt: conversation.updatedAt?.getTime() ?? now,
|
||||
isPinned: conversation.isPinned,
|
||||
isArchived: conversation.isArchived,
|
||||
messageCount: conversation.messageCount
|
||||
messageCount: conversation.messageCount,
|
||||
systemPromptId: conversation.systemPromptId ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +141,8 @@ export async function createConversation(
|
||||
isPinned: data.isPinned ?? false,
|
||||
isArchived: data.isArchived ?? false,
|
||||
messageCount: 0,
|
||||
syncVersion: 1
|
||||
syncVersion: 1,
|
||||
systemPromptId: data.systemPromptId ?? null
|
||||
};
|
||||
|
||||
await db.conversations.add(stored);
|
||||
@@ -279,6 +282,16 @@ export async function updateMessageCount(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the system prompt for a conversation
|
||||
*/
|
||||
export async function updateSystemPrompt(
|
||||
conversationId: string,
|
||||
systemPromptId: string | null
|
||||
): Promise<StorageResult<Conversation>> {
|
||||
return updateConversation(conversationId, { systemPromptId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Search conversations by title
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface StoredConversation {
|
||||
isArchived: boolean;
|
||||
messageCount: number;
|
||||
syncVersion?: number;
|
||||
/** Optional system prompt ID for this conversation */
|
||||
systemPromptId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,8 @@ export interface ConversationRecord {
|
||||
isArchived: boolean;
|
||||
messageCount: number;
|
||||
syncVersion?: number;
|
||||
/** Optional system prompt ID for this conversation */
|
||||
systemPromptId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +190,19 @@ class OllamaDatabase extends Dexie {
|
||||
// System prompt templates
|
||||
prompts: 'id, name, isDefault, updatedAt'
|
||||
});
|
||||
|
||||
// Version 4: Per-conversation system prompts
|
||||
// Note: No schema change needed - just adding optional field to conversations
|
||||
// Dexie handles this gracefully (field is undefined on old records)
|
||||
this.version(4).stores({
|
||||
conversations: 'id, updatedAt, isPinned, isArchived, systemPromptId',
|
||||
messages: 'id, conversationId, parentId, createdAt',
|
||||
attachments: 'id, messageId',
|
||||
syncQueue: 'id, entityType, createdAt',
|
||||
documents: 'id, name, createdAt, updatedAt',
|
||||
chunks: 'id, documentId',
|
||||
prompts: 'id, name, isDefault, updatedAt'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
pinConversation,
|
||||
archiveConversation,
|
||||
updateMessageCount,
|
||||
updateSystemPrompt,
|
||||
searchConversations
|
||||
} from './conversations.js';
|
||||
|
||||
@@ -40,8 +41,10 @@ export {
|
||||
getMessageTree,
|
||||
getSiblings,
|
||||
getPathToMessage,
|
||||
appendToMessage
|
||||
appendToMessage,
|
||||
searchMessages
|
||||
} from './messages.js';
|
||||
export type { MessageSearchResult } from './messages.js';
|
||||
|
||||
// Attachment operations
|
||||
export {
|
||||
|
||||
@@ -387,3 +387,68 @@ export async function appendToMessage(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Search result for message content search */
|
||||
export interface MessageSearchResult {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
matchIndex: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search messages by content
|
||||
* Returns matching messages with context snippets
|
||||
*/
|
||||
export async function searchMessages(
|
||||
query: string,
|
||||
options?: {
|
||||
conversationId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<StorageResult<MessageSearchResult[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
if (!query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase().trim();
|
||||
const limit = options?.limit ?? 50;
|
||||
|
||||
// Get messages (optionally filtered by conversation)
|
||||
let messagesQuery = db.messages.orderBy('createdAt').reverse();
|
||||
|
||||
const allMessages = await messagesQuery.toArray();
|
||||
|
||||
// Filter by content match and optional conversation filter
|
||||
const matches: MessageSearchResult[] = [];
|
||||
|
||||
for (const msg of allMessages) {
|
||||
if (matches.length >= limit) break;
|
||||
|
||||
// Skip if filtering by conversation and doesn't match
|
||||
if (options?.conversationId && msg.conversationId !== options.conversationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for content match
|
||||
const lowerContent = msg.content.toLowerCase();
|
||||
const matchIndex = lowerContent.indexOf(lowerQuery);
|
||||
|
||||
if (matchIndex !== -1) {
|
||||
matches.push({
|
||||
messageId: msg.id,
|
||||
conversationId: msg.conversationId,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
matchIndex,
|
||||
createdAt: new Date(msg.createdAt)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,20 +23,20 @@ export class ChatState {
|
||||
streamingMessageId = $state<string | null>(null);
|
||||
streamBuffer = $state('');
|
||||
|
||||
// Derived: Get visible messages along the active path (excluding hidden messages for UI)
|
||||
// Derived: Get visible messages along the active path (excluding hidden and summarized messages for UI)
|
||||
visibleMessages = $derived.by(() => {
|
||||
const messages: MessageNode[] = [];
|
||||
for (const id of this.activePath) {
|
||||
const node = this.messageTree.get(id);
|
||||
// Skip hidden messages (e.g., internal tool result context)
|
||||
if (node && !node.message.hidden) {
|
||||
// Skip hidden messages and summarized messages (but show summary messages)
|
||||
if (node && !node.message.hidden && !node.message.isSummarized) {
|
||||
messages.push(node);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
});
|
||||
|
||||
// Derived: Get ALL messages along active path (including hidden, for API calls)
|
||||
// Derived: Get ALL messages along active path (including hidden, for storage)
|
||||
allMessages = $derived.by(() => {
|
||||
const messages: MessageNode[] = [];
|
||||
for (const id of this.activePath) {
|
||||
@@ -48,6 +48,19 @@ export class ChatState {
|
||||
return messages;
|
||||
});
|
||||
|
||||
// Derived: Get messages for API context (excludes summarized originals, includes summaries)
|
||||
messagesForContext = $derived.by(() => {
|
||||
const messages: MessageNode[] = [];
|
||||
for (const id of this.activePath) {
|
||||
const node = this.messageTree.get(id);
|
||||
// Exclude summarized messages but include summary messages and hidden tool results
|
||||
if (node && !node.message.isSummarized) {
|
||||
messages.push(node);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
});
|
||||
|
||||
// Derived: Can regenerate the last assistant message
|
||||
canRegenerate = $derived.by(() => {
|
||||
if (this.activePath.length === 0 || this.isStreaming) {
|
||||
@@ -364,6 +377,126 @@ export class ChatState {
|
||||
return Array.from(this.messageTree.values()).filter((node) => node.childIds.length === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as summarized (they'll be hidden from UI and context)
|
||||
* @param messageIds Array of message IDs to mark as summarized
|
||||
*/
|
||||
markAsSummarized(messageIds: string[]): void {
|
||||
const newTree = new Map(this.messageTree);
|
||||
|
||||
for (const id of messageIds) {
|
||||
const node = newTree.get(id);
|
||||
if (node) {
|
||||
newTree.set(id, {
|
||||
...node,
|
||||
message: {
|
||||
...node.message,
|
||||
isSummarized: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.messageTree = newTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a summary message at the beginning of the conversation (after system message if present)
|
||||
* @param summaryText The summary content
|
||||
* @returns The ID of the inserted summary message
|
||||
*/
|
||||
insertSummaryMessage(summaryText: string): string {
|
||||
const id = generateId();
|
||||
|
||||
// Find the first non-system message position in the active path
|
||||
let insertIndex = 0;
|
||||
for (let i = 0; i < this.activePath.length; i++) {
|
||||
const node = this.messageTree.get(this.activePath[i]);
|
||||
if (node?.message.role !== 'system') {
|
||||
insertIndex = i;
|
||||
break;
|
||||
}
|
||||
insertIndex = i + 1;
|
||||
}
|
||||
|
||||
// Determine parent: either the last system message or null
|
||||
const parentId = insertIndex > 0 ? this.activePath[insertIndex - 1] : null;
|
||||
|
||||
// Find the original first non-summarized message to connect to
|
||||
let nextMessageId: string | null = null;
|
||||
for (let i = insertIndex; i < this.activePath.length; i++) {
|
||||
const node = this.messageTree.get(this.activePath[i]);
|
||||
if (node && !node.message.isSummarized) {
|
||||
nextMessageId = this.activePath[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the summary node
|
||||
const summaryNode: MessageNode = {
|
||||
id,
|
||||
message: {
|
||||
role: 'system',
|
||||
content: `[Previous conversation summary]\n\n${summaryText}`,
|
||||
isSummary: true
|
||||
},
|
||||
parentId,
|
||||
childIds: nextMessageId ? [nextMessageId] : [],
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
const newTree = new Map(this.messageTree);
|
||||
|
||||
// Update parent to point to summary instead of original child
|
||||
if (parentId) {
|
||||
const parent = newTree.get(parentId);
|
||||
if (parent && nextMessageId) {
|
||||
newTree.set(parentId, {
|
||||
...parent,
|
||||
childIds: parent.childIds.map((cid) => (cid === nextMessageId ? id : cid))
|
||||
});
|
||||
} else if (parent) {
|
||||
newTree.set(parentId, {
|
||||
...parent,
|
||||
childIds: [...parent.childIds, id]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update next message's parent to point to summary
|
||||
if (nextMessageId) {
|
||||
const nextNode = newTree.get(nextMessageId);
|
||||
if (nextNode) {
|
||||
newTree.set(nextMessageId, {
|
||||
...nextNode,
|
||||
parentId: id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add summary node
|
||||
newTree.set(id, summaryNode);
|
||||
this.messageTree = newTree;
|
||||
|
||||
// Update root if summary is at the beginning
|
||||
if (!parentId) {
|
||||
this.rootMessageId = id;
|
||||
}
|
||||
|
||||
// Rebuild active path to include summary
|
||||
const newPath: string[] = [];
|
||||
for (let i = 0; i < insertIndex; i++) {
|
||||
newPath.push(this.activePath[i]);
|
||||
}
|
||||
newPath.push(id);
|
||||
for (let i = insertIndex; i < this.activePath.length; i++) {
|
||||
newPath.push(this.activePath[i]);
|
||||
}
|
||||
this.activePath = newPath;
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the chat state
|
||||
*/
|
||||
|
||||
@@ -195,6 +195,15 @@ export class ConversationsState {
|
||||
clearSearch(): void {
|
||||
this.searchQuery = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the system prompt for a conversation
|
||||
* @param id The conversation ID
|
||||
* @param systemPromptId The prompt ID (or null to clear)
|
||||
*/
|
||||
setSystemPrompt(id: string, systemPromptId: string | null): void {
|
||||
this.update(id, { systemPromptId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton conversations state instance */
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ollamaClient } from '$lib/ollama/client.js';
|
||||
import { fetchRemoteModels, type RemoteModel } from '$lib/api/model-registry';
|
||||
|
||||
/** Known vision model families/patterns (fallback if API doesn't report) */
|
||||
const VISION_PATTERNS = ['llava', 'bakllava', 'moondream', 'vision'];
|
||||
const VISION_PATTERNS = ['llava', 'bakllava', 'moondream', 'vision', 'ministral', 'pixtral', 'minicpm-v'];
|
||||
|
||||
/** Capability display metadata */
|
||||
export const CAPABILITY_INFO: Record<string, { label: string; icon: string; color: string }> = {
|
||||
@@ -128,8 +128,15 @@ export class ModelsState {
|
||||
});
|
||||
|
||||
// Derived: Check if selected model supports vision
|
||||
// Uses capabilities cache first (from Ollama API), falls back to pattern matching
|
||||
selectedSupportsVision = $derived.by(() => {
|
||||
if (!this.selected) return false;
|
||||
// Check capabilities cache first (most accurate)
|
||||
const caps = this.capabilitiesCache.get(this.selected.name);
|
||||
if (caps) {
|
||||
return caps.includes('vision');
|
||||
}
|
||||
// Fallback to pattern matching
|
||||
return isVisionModel(this.selected);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ export interface Message {
|
||||
toolCalls?: ToolCall[];
|
||||
/** If true, message is hidden from UI (e.g., internal tool result messages) */
|
||||
hidden?: boolean;
|
||||
/** If true, this message has been summarized and should be excluded from context */
|
||||
isSummarized?: boolean;
|
||||
/** If true, this is a summary message representing compressed conversation history */
|
||||
isSummary?: boolean;
|
||||
}
|
||||
|
||||
/** A node in the message tree structure (for branching conversations) */
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface Conversation {
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
messageCount: number;
|
||||
/** Optional system prompt ID for this conversation (null = use global default) */
|
||||
systemPromptId?: string | null;
|
||||
}
|
||||
|
||||
/** Full conversation including message tree and navigation state */
|
||||
|
||||
@@ -237,6 +237,18 @@ export function getShortcuts() {
|
||||
modifiers: getPrimaryModifiers(),
|
||||
description: 'Toggle sidebar'
|
||||
},
|
||||
FOCUS_INPUT: {
|
||||
id: 'focus-input',
|
||||
key: '/',
|
||||
modifiers: getPrimaryModifiers(),
|
||||
description: 'Focus chat input'
|
||||
},
|
||||
SHOW_SHORTCUTS: {
|
||||
id: 'show-shortcuts',
|
||||
key: '?',
|
||||
modifiers: { shift: true },
|
||||
description: 'Show keyboard shortcuts'
|
||||
},
|
||||
CLOSE_MODAL: {
|
||||
id: 'close-modal',
|
||||
key: 'Escape',
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import Sidenav from '$lib/components/layout/Sidenav.svelte';
|
||||
import TopNav from '$lib/components/layout/TopNav.svelte';
|
||||
import ModelSelect from '$lib/components/layout/ModelSelect.svelte';
|
||||
import { ToastContainer } from '$lib/components/shared';
|
||||
import { ToastContainer, ShortcutsModal, SearchModal } from '$lib/components/shared';
|
||||
|
||||
import type { LayoutData } from './$types';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -32,6 +32,9 @@
|
||||
// Search modal state
|
||||
let showSearchModal = $state(false);
|
||||
|
||||
// Shortcuts modal state
|
||||
let showShortcutsModal = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Initialize UI state (handles responsive detection, theme, etc.)
|
||||
uiState.initialize();
|
||||
@@ -82,20 +85,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Search (Cmd/Ctrl + K)
|
||||
// Search (Cmd/Ctrl + K) - opens global search modal
|
||||
keyboardShortcuts.register({
|
||||
...SHORTCUTS.SEARCH,
|
||||
preventDefault: true,
|
||||
handler: () => {
|
||||
// Focus the search input in sidenav if open, otherwise open sidenav
|
||||
if (!uiState.sidenavOpen) {
|
||||
uiState.openSidenav();
|
||||
}
|
||||
// Focus search after a short delay to allow sidenav to open
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
|
||||
searchInput?.focus();
|
||||
}, 100);
|
||||
showSearchModal = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -107,6 +102,25 @@
|
||||
uiState.toggleSidenav();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus chat input (Cmd/Alt + /)
|
||||
keyboardShortcuts.register({
|
||||
...SHORTCUTS.FOCUS_INPUT,
|
||||
preventDefault: true,
|
||||
handler: () => {
|
||||
const chatInput = document.querySelector('[data-chat-input]') as HTMLTextAreaElement;
|
||||
chatInput?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Show keyboard shortcuts (Shift + ?)
|
||||
keyboardShortcuts.register({
|
||||
...SHORTCUTS.SHOW_SHORTCUTS,
|
||||
preventDefault: true,
|
||||
handler: () => {
|
||||
showShortcutsModal = !showShortcutsModal;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,3 +171,9 @@
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<ToastContainer />
|
||||
|
||||
<!-- Keyboard shortcuts help -->
|
||||
<ShortcutsModal isOpen={showShortcutsModal} onClose={() => (showShortcutsModal = false)} />
|
||||
|
||||
<!-- Global search modal -->
|
||||
<SearchModal isOpen={showSearchModal} onClose={() => (showSearchModal = false)} />
|
||||
|
||||
Reference in New Issue
Block a user