Files
vessel/frontend/src/lib/components/chat/ChatWindow.svelte
vikingowl 5cab71dd78 fix: sync context progress bar with custom context length setting
- Add customMaxTokens override to ContextManager
- maxTokens is now derived from custom override or model default
- ChatWindow syncs settings.num_ctx to context manager
- Progress bar now shows custom context length when enabled
2026-01-02 21:04:47 +01:00

965 lines
32 KiB
Svelte

<script lang="ts">
/**
* ChatWindow - Main container for the chat interface
* Handles sending messages, streaming responses, and tool execution
*/
import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState } from '$lib/stores';
import { serverConversationsState } from '$lib/stores/server-conversations.svelte';
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
import { ollamaClient } from '$lib/ollama';
import { addMessage as addStoredMessage, updateConversation, createConversation as createStoredConversation } from '$lib/storage';
import {
contextManager,
generateSummary,
selectMessagesForSummarization,
calculateTokenSavings,
formatSummaryAsContext,
searchSimilar,
formatResultsAsContext,
getKnowledgeBaseStats
} from '$lib/memory';
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 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';
/**
* Props interface for ChatWindow
* - mode: 'new' for new chat page, 'conversation' for existing conversations
* - onFirstMessage: callback for when first message is sent in 'new' mode
* - conversation: conversation metadata when in 'conversation' mode
*/
interface Props {
mode?: 'new' | 'conversation';
onFirstMessage?: (content: string, images?: string[]) => Promise<void>;
conversation?: Conversation | null;
/** Bindable prop for thinking mode - synced with parent in 'new' mode */
thinkingEnabled?: boolean;
}
let {
mode = 'new',
onFirstMessage,
conversation,
thinkingEnabled = $bindable(true)
}: Props = $props();
// Local state for abort controller
let abortController: AbortController | null = $state(null);
// 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);
// RAG (Retrieval-Augmented Generation) state
let ragEnabled = $state(true);
let hasKnowledgeBase = $state(false);
let lastRagContext = $state<string | null>(null);
// System prompt for new conversations (before a conversation is created)
let newChatPromptId = $state<string | null>(null);
// File picker trigger function (bound from ChatInput -> FileUpload)
let triggerFilePicker: (() => void) | undefined = $state();
// Derived: Check if selected model supports thinking
const supportsThinking = $derived.by(() => {
const caps = modelsState.selectedCapabilities;
return caps.includes('thinking');
});
// Check for knowledge base on mount
$effect(() => {
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
*/
async function checkKnowledgeBase(): Promise<void> {
try {
const stats = await getKnowledgeBaseStats();
hasKnowledgeBase = stats.documentCount > 0;
} catch {
hasKnowledgeBase = false;
}
}
/**
* Retrieve relevant context from knowledge base for the query
*/
async function retrieveRagContext(query: string): Promise<string | null> {
if (!ragEnabled || !hasKnowledgeBase) return null;
try {
const results = await searchSimilar(query, 3, 0.5);
if (results.length === 0) return null;
const context = formatResultsAsContext(results);
return context;
} catch (error) {
console.error('[RAG] Failed to retrieve context:', error);
return null;
}
}
/**
* Convert OllamaToolCall to the format expected by tool executor
* Ollama doesn't provide IDs, so we generate them
*/
function convertToolCalls(ollamaCalls: OllamaToolCall[]): Array<{ id: string; function: { name: string; arguments: string } }> {
return ollamaCalls.map((call, index) => ({
id: `tool-${Date.now()}-${index}`,
function: {
name: call.function.name,
arguments: JSON.stringify(call.function.arguments)
}
}));
}
/**
* Get tool definitions for the API call
*/
function getToolsForApi(): OllamaToolDefinition[] | undefined {
if (!toolsState.toolsEnabled) return undefined;
const tools = toolsState.getEnabledToolDefinitions();
return tools.length > 0 ? tools as OllamaToolDefinition[] : undefined;
}
// Derived: Check if there are any messages
const hasMessages = $derived(chatState.visibleMessages.length > 0);
// Update context manager when model changes
$effect(() => {
const model = modelsState.selectedId;
if (model) {
contextManager.setModel(model);
}
});
// Sync custom context limit with settings
$effect(() => {
if (settingsState.useCustomParameters) {
contextManager.setCustomContextLimit(settingsState.num_ctx);
} else {
contextManager.setCustomContextLimit(null);
}
});
// Update context manager when messages change
$effect(() => {
contextManager.updateMessages(chatState.visibleMessages);
});
// Invalidate streaming message token cache on content update
// Only do this occasionally (the throttling in contextManager handles the rest)
$effect(() => {
if (chatState.streamingMessageId && chatState.streamBuffer) {
contextManager.invalidateMessage(chatState.streamingMessageId);
}
});
// Flush pending context updates when streaming finishes
$effect(() => {
if (!chatState.isStreaming) {
// Force a full context update when streaming ends
contextManager.flushPendingUpdate();
contextManager.updateMessages(chatState.visibleMessages, true);
}
});
/**
* Convert chat state messages to Ollama API format
* Uses messagesForContext to exclude summarized originals but include summaries
*/
function getMessagesForApi(): OllamaMessage[] {
return chatState.messagesForContext.map((node) => ({
role: node.message.role as OllamaMessage['role'],
content: node.message.content,
images: node.message.images
}));
}
/**
* Handle summarization of older messages
*/
async function handleSummarize(): Promise<void> {
const selectedModel = modelsState.selectedId;
if (!selectedModel || isSummarizing) return;
const messages = chatState.visibleMessages;
const { toSummarize, toKeep } = selectMessagesForSummarization(messages, 0);
if (toSummarize.length === 0) {
toastState.warning('No messages available to summarize');
return;
}
isSummarizing = true;
try {
// Generate summary using the LLM
const summary = await generateSummary(toSummarize, selectedModel);
// Calculate savings for logging
const savedTokens = calculateTokenSavings(toSummarize, summary);
// 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
// =========================================================================
/**
* 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;
if (!selectedModel) {
toastState.error('Please select a model first');
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);
return;
}
let conversationId = chatState.conversationId;
// Auto-create conversation if none exists (fallback for edge cases)
if (!conversationId) {
const title = content.length > 50 ? content.substring(0, 47) + '...' : content;
const result = await createStoredConversation({
title,
model: selectedModel,
isPinned: false,
isArchived: false
});
if (result.success) {
conversationId = result.data.id;
chatState.conversationId = conversationId;
conversationsState.add(result.data);
}
}
// Add user message to tree
const userMessageId = chatState.addMessage({
role: 'user',
content,
images
});
// Persist user message to IndexedDB with the SAME ID as chatState
if (conversationId) {
const parentId = chatState.activePath.length >= 2
? chatState.activePath[chatState.activePath.length - 2]
: null;
await addStoredMessage(conversationId, { role: 'user', content, images }, parentId, userMessageId);
}
// Stream assistant message with optional tool support
await streamAssistantResponse(selectedModel, userMessageId, conversationId);
}
/**
* Stream assistant response with tool call handling and RAG context
*/
async function streamAssistantResponse(
model: string,
parentMessageId: string,
conversationId: string | null
): Promise<void> {
const assistantMessageId = chatState.startStreaming();
abortController = new AbortController();
// Start streaming metrics tracking
streamingMetricsState.startStream();
// Track tool calls received during streaming
let pendingToolCalls: OllamaToolCall[] | null = null;
try {
let messages = getMessagesForApi();
const tools = getToolsForApi();
// Build system prompt from active prompt + thinking + RAG context
const systemParts: string[] = [];
// Wait for prompts to be loaded
await promptsState.ready();
// Priority: per-conversation prompt > new chat 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 (newChatPromptId) {
// Use new chat selected prompt (before conversation is created)
const newChatPrompt = promptsState.get(newChatPromptId);
if (newChatPrompt) {
promptContent = newChatPrompt.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
const lastUserMessage = messages.filter(m => m.role === 'user').pop();
if (lastUserMessage && ragEnabled && hasKnowledgeBase) {
const ragContext = await retrieveRagContext(lastUserMessage.content);
if (ragContext) {
lastRagContext = ragContext;
systemParts.push(`You have access to a knowledge base. Use the following relevant context to help answer the user's question. If the context isn't relevant, you can ignore it.\n\n${ragContext}`);
}
}
// Inject combined system message
if (systemParts.length > 0) {
const systemMessage: OllamaMessage = {
role: 'system',
content: systemParts.join('\n\n---\n\n')
};
messages = [systemMessage, ...messages];
}
// Use function model for tool routing if enabled and tools are present
const chatModel = (tools && tools.length > 0 && USE_FUNCTION_MODEL)
? getFunctionModel(model)
: model;
// Determine if we should use native thinking mode
const useNativeThinking = supportsThinking && thinkingEnabled;
// Track thinking content during streaming
let streamingThinking = '';
let thinkingClosed = false;
await ollamaClient.streamChatWithCallbacks(
{
model: chatModel,
messages,
tools,
think: useNativeThinking,
options: settingsState.apiParameters
},
{
onThinkingToken: (token) => {
// Accumulate thinking and update the message
if (!streamingThinking) {
// Start the thinking block
chatState.appendToStreaming('<think>');
}
streamingThinking += token;
chatState.appendToStreaming(token);
// Track thinking tokens for metrics
streamingMetricsState.incrementTokens();
},
onToken: (token) => {
// Close thinking block when content starts
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\n\n');
thinkingClosed = true;
}
chatState.appendToStreaming(token);
// Track content tokens for metrics
streamingMetricsState.incrementTokens();
},
onToolCall: (toolCalls) => {
// Store tool calls to process after streaming completes
pendingToolCalls = toolCalls;
},
onComplete: async () => {
// Close thinking block if it was opened but not closed (e.g., tool calls without content)
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\n\n');
thinkingClosed = true;
}
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
// Handle tool calls if received
if (pendingToolCalls && pendingToolCalls.length > 0) {
await executeToolsAndContinue(
model,
assistantMessageId,
pendingToolCalls,
conversationId
);
return; // Tool continuation handles persistence
}
// Persist assistant message to IndexedDB with the SAME ID as chatState
if (conversationId) {
const node = chatState.messageTree.get(assistantMessageId);
if (node) {
await addStoredMessage(
conversationId,
{ role: 'assistant', content: node.message.content },
parentMessageId,
assistantMessageId
);
await updateConversation(conversationId, {});
conversationsState.update(conversationId, {});
}
}
},
onError: (error) => {
console.error('Streaming error:', error);
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
}
},
abortController.signal
);
} catch (error) {
toastState.error('Failed to send message. Please try again.');
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
}
}
/**
* Execute tool calls and continue the conversation with results
*/
async function executeToolsAndContinue(
model: string,
assistantMessageId: string,
toolCalls: OllamaToolCall[],
conversationId: string | null
): Promise<void> {
isExecutingTools = true;
try {
// Convert tool calls to executor format with stable IDs
const callIds = toolCalls.map(() => crypto.randomUUID());
const convertedCalls = toolCalls.map((tc, i) => ({
id: callIds[i],
name: tc.function.name,
arguments: tc.function.arguments
}));
// Execute all tools (including custom tools)
const results = await runToolCalls(convertedCalls, undefined, toolsState.customTools);
// Format results for model context (still needed for LLM to respond)
const toolResultContent = formatToolResultsForChat(results);
// Update the assistant message with structured tool call data (including results)
const assistantNode = chatState.messageTree.get(assistantMessageId);
if (assistantNode) {
// Store structured tool call data WITH results for display
// Results are shown collapsed in ToolCallDisplay - NOT as raw message content
assistantNode.message.toolCalls = toolCalls.map((tc, i) => {
const result = results[i];
return {
id: callIds[i],
name: tc.function.name,
arguments: JSON.stringify(tc.function.arguments),
result: result.success ? (typeof result.result === 'object' ? JSON.stringify(result.result) : String(result.result)) : undefined,
error: result.success ? undefined : result.error
};
});
// DON'T add tool results to message content - that's what floods the UI
// The results are stored in toolCalls and displayed by ToolCallDisplay
}
// Persist the assistant message (including toolCalls for reload persistence)
if (conversationId && assistantNode) {
const parentOfAssistant = assistantNode.parentId;
await addStoredMessage(
conversationId,
{
role: 'assistant',
content: assistantNode.message.content,
toolCalls: assistantNode.message.toolCalls
},
parentOfAssistant,
assistantMessageId
);
}
// Add tool results as a hidden message (for model context, not displayed in UI)
const toolMessageId = chatState.addMessage({
role: 'user',
content: `Tool execution results:\n${toolResultContent}\n\nBased on these results, either provide a helpful response OR call another tool if you need more information.`,
hidden: true
});
if (conversationId) {
await addStoredMessage(
conversationId,
{ role: 'user', content: `Tool execution results:\n${toolResultContent}` },
assistantMessageId,
toolMessageId
);
}
// Stream the final response
await streamAssistantResponse(model, toolMessageId, conversationId);
} catch (error) {
toastState.error('Tool execution failed');
// Update assistant message with error
const node = chatState.messageTree.get(assistantMessageId);
if (node) {
node.message.content = `Tool execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
} finally {
isExecutingTools = false;
}
}
/**
* Stop the current streaming response
*/
function handleStopStreaming(): void {
if (abortController) {
abortController.abort();
abortController = null;
}
chatState.finishStreaming();
}
/**
* Regenerate the last assistant response
* Creates a new sibling message for the assistant response and streams a new answer
*/
async function handleRegenerate(): Promise<void> {
if (!chatState.canRegenerate) return;
const selectedModel = modelsState.selectedId;
if (!selectedModel) return;
// Get the last message (should be an assistant message)
const lastMessageId = chatState.activePath[chatState.activePath.length - 1];
const lastNode = chatState.messageTree.get(lastMessageId);
if (!lastNode || lastNode.message.role !== 'assistant') return;
const conversationId = chatState.conversationId;
// Use the new startRegeneration method which creates a sibling and sets up streaming
const newMessageId = chatState.startRegeneration(lastMessageId);
if (!newMessageId) {
toastState.error('Failed to regenerate response');
return;
}
// Get the parent user message for context
const parentUserMessage = chatState.getParentUserMessage(newMessageId);
const parentUserMessageId = parentUserMessage?.id;
abortController = new AbortController();
// Start streaming metrics tracking
streamingMetricsState.startStream();
// Track tool calls received during streaming
let pendingToolCalls: OllamaToolCall[] | null = null;
try {
// Get messages for API - excludes the current empty assistant message being streamed
const messages = getMessagesForApi().filter(m => m.content !== '');
const tools = getToolsForApi();
// Use function model for tool routing if enabled and tools are present
const chatModel = (tools && tools.length > 0 && USE_FUNCTION_MODEL)
? getFunctionModel(selectedModel)
: selectedModel;
await ollamaClient.streamChatWithCallbacks(
{
model: chatModel,
messages,
tools,
options: settingsState.apiParameters
},
{
onToken: (token) => {
chatState.appendToStreaming(token);
streamingMetricsState.incrementTokens();
},
onToolCall: (toolCalls) => {
pendingToolCalls = toolCalls;
},
onComplete: async () => {
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
// Handle tool calls if received
if (pendingToolCalls && pendingToolCalls.length > 0) {
await executeToolsAndContinue(
selectedModel,
newMessageId,
pendingToolCalls,
conversationId
);
return;
}
// Persist regenerated assistant message to IndexedDB with the SAME ID
if (conversationId && parentUserMessageId) {
const node = chatState.messageTree.get(newMessageId);
if (node) {
await addStoredMessage(
conversationId,
{ role: 'assistant', content: node.message.content },
parentUserMessageId,
newMessageId
);
// Update conversation timestamp
await updateConversation(conversationId, {});
conversationsState.update(conversationId, {});
}
}
},
onError: (error) => {
console.error('Regenerate error:', error);
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
}
},
abortController.signal
);
} catch (error) {
toastState.error('Failed to regenerate. Please try again.');
chatState.finishStreaming();
streamingMetricsState.endStream();
abortController = null;
}
}
/**
* Edit a user message and regenerate
* Creates a new sibling user message and triggers a new assistant response
*/
async function handleEditMessage(messageId: string, newContent: string): Promise<void> {
const selectedModel = modelsState.selectedId;
if (!selectedModel) return;
// Find the message
const node = chatState.messageTree.get(messageId);
if (!node || node.message.role !== 'user') return;
const conversationId = chatState.conversationId;
// Use the new startEditWithNewBranch method which creates a sibling user message
const newUserMessageId = chatState.startEditWithNewBranch(
messageId,
newContent,
node.message.images
);
if (!newUserMessageId) {
toastState.error('Failed to edit message');
return;
}
// Persist the new user message to IndexedDB with the SAME ID
if (conversationId) {
// Get the parent of the original message (which is also the parent of our new message)
const parentId = node.parentId;
await addStoredMessage(
conversationId,
{ role: 'user', content: newContent, images: node.message.images },
parentId,
newUserMessageId
);
}
// Stream the response using the shared function (with tool support)
await streamAssistantResponse(selectedModel, newUserMessageId, conversationId);
}
</script>
<div class="flex h-full flex-col bg-theme-primary">
{#if hasMessages}
<div class="flex-1 overflow-hidden">
<MessageList
onRegenerate={handleRegenerate}
onEditMessage={handleEditMessage}
showThinking={thinkingEnabled}
/>
</div>
{:else}
<div class="flex flex-1 items-center justify-center">
<EmptyState />
</div>
{/if}
<!-- Input area with subtle gradient fade -->
<div class="relative">
<!-- Gradient fade at top -->
<div class="pointer-events-none absolute -top-8 left-0 right-0 h-8 bg-gradient-to-t from-[var(--color-bg-primary)] to-transparent"></div>
<div class="border-t border-theme bg-theme-primary/95 backdrop-blur-sm">
<!-- Summary recommendation banner -->
<SummaryBanner onSummarize={handleSummarize} isLoading={isSummarizing} />
<!-- Context usage indicator -->
{#if hasMessages}
<div class="px-4 pt-3">
<ContextUsageBar />
</div>
{/if}
<!-- Streaming performance stats -->
<div class="flex justify-center px-4 pt-2">
<StreamingStats />
</div>
<!-- Chat options bar: [Custom] [System Prompt] ... [Attach] [Thinking] -->
<div class="flex items-center justify-between gap-3 px-4 pt-3">
<!-- 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-theme-muted transition-colors hover:bg-theme-hover hover:text-theme-primary"
class:bg-theme-secondary={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 -->
{#if mode === 'conversation' && conversation}
<SystemPromptSelector
conversationId={conversation.id}
currentPromptId={conversation.systemPromptId}
/>
{:else if mode === 'new'}
<SystemPromptSelector
currentPromptId={newChatPromptId}
onSelect={(promptId) => (newChatPromptId = promptId)}
/>
{/if}
</div>
<!-- Right side: Attach files + Thinking mode toggle -->
<div class="flex items-center gap-3">
<!-- Attach files button -->
<button
type="button"
onclick={() => triggerFilePicker?.()}
disabled={!modelsState.selectedId}
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-theme-muted transition-colors hover:bg-theme-hover hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Attach files"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
<path fill-rule="evenodd" d="M15.621 4.379a3 3 0 0 0-4.242 0l-7 7a3 3 0 0 0 4.241 4.243h.001l.497-.5a.75.75 0 0 1 1.064 1.057l-.498.501-.002.002a4.5 4.5 0 0 1-6.364-6.364l7-7a4.5 4.5 0 0 1 6.368 6.36l-3.455 3.553A2.625 2.625 0 1 1 9.52 9.52l3.45-3.451a.75.75 0 1 1 1.061 1.06l-3.45 3.451a1.125 1.125 0 0 0 1.587 1.595l3.454-3.553a3 3 0 0 0 0-4.242Z" clip-rule="evenodd" />
</svg>
<span>Attach</span>
</button>
<!-- Thinking mode toggle -->
{#if supportsThinking}
<label class="flex cursor-pointer items-center gap-2 text-xs text-theme-muted">
<span class="flex items-center gap-1">
<span class="text-amber-400">🧠</span>
Thinking
</span>
<button
type="button"
role="switch"
aria-checked={thinkingEnabled}
onclick={() => (thinkingEnabled = !thinkingEnabled)}
class="relative inline-flex h-5 w-9 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-amber-500 focus:ring-offset-2 focus:ring-offset-theme-primary {thinkingEnabled ? 'bg-amber-600' : 'bg-theme-tertiary'}"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {thinkingEnabled ? 'translate-x-4' : 'translate-x-0'}"
></span>
</button>
</label>
{/if}
</div>
</div>
<!-- Model parameters panel -->
<div class="px-4 pt-2">
<ModelParametersPanel />
</div>
<div class="px-4 pb-4 pt-2">
<ChatInput
onSend={handleSendMessage}
onStop={handleStopStreaming}
isStreaming={chatState.isStreaming}
disabled={!modelsState.selectedId}
hideAttachButton={true}
bind:triggerFilePicker
/>
</div>
</div>
</div>
</div>
<!-- Context full modal -->
<ContextFullModal
isOpen={showContextFullModal}
onSummarize={handleContextFullSummarize}
onNewChat={handleContextFullNewChat}
onDismiss={handleContextFullDismiss}
{isSummarizing}
canSummarize={canSummarizeConversation}
/>