From 144148125b1016f11c9fbf1e8a939486692fa144 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 12 Jan 2026 09:32:32 +0100 Subject: [PATCH] refactor: Cleanup --- .../webui/src/lib/clients/agentic.client.ts | 622 ++++++ .../webui/src/lib/clients/chat.client.ts | 1585 +++++++++++++++ .../src/lib/clients/conversations.client.ts | 713 +++++++ tools/server/webui/src/lib/clients/index.ts | 37 + .../webui/src/lib/clients/mcp.client.ts | 615 ++++++ .../webui/src/lib/clients/openai-sse.ts | 190 -- .../chat/ChatMessages/AgenticContent.svelte | 117 +- .../ChatMessages/ChatMessageAssistant.svelte | 37 +- .../ChatMessages/ChatMessageStatistics.svelte | 108 +- .../lib/components/app/mcp/McpSelector.svelte | 2 +- .../app/mcp/McpSettingsSection.svelte | 2 +- .../app/models/ModelsSelector.svelte | 2 +- tools/server/webui/src/lib/config/agentic.ts | 37 - tools/server/webui/src/lib/config/mcp.ts | 190 -- tools/server/webui/src/lib/enums/chat.ts | 4 +- .../lib/hooks/use-processing-state.svelte.ts | 2 +- .../server/webui/src/lib/mcp/host-manager.ts | 408 ---- tools/server/webui/src/lib/mcp/index.ts | 6 - .../webui/src/lib/mcp/server-connection.ts | 286 --- .../lib/services/{chat.ts => chat.service.ts} | 53 +- .../{database.ts => database.service.ts} | 40 +- tools/server/webui/src/lib/services/index.ts | 11 +- .../webui/src/lib/services/mcp.service.ts | 233 +++ .../services/{models.ts => models.service.ts} | 30 +- ...spec.ts => parameter-sync.service.spec.ts} | 2 +- ...eter-sync.ts => parameter-sync.service.ts} | 40 +- .../services/{props.ts => props.service.ts} | 10 +- .../webui/src/lib/stores/agentic.svelte.ts | 507 +---- .../webui/src/lib/stores/chat.svelte.ts | 1727 ++--------------- .../src/lib/stores/conversations.svelte.ts | 890 ++------- .../server/webui/src/lib/stores/mcp.svelte.ts | 425 +--- .../webui/src/lib/stores/models.svelte.ts | 98 +- .../webui/src/lib/stores/server.svelte.ts | 42 +- .../webui/src/lib/stores/settings.svelte.ts | 72 +- tools/server/webui/src/lib/types/chat.d.ts | 33 + .../server/webui/src/lib/types/database.d.ts | 15 +- tools/server/webui/src/lib/types/mcp.d.ts | 93 + tools/server/webui/src/lib/types/mcp.ts | 132 -- tools/server/webui/src/lib/types/models.d.ts | 4 - .../server/webui/src/lib/types/settings.d.ts | 2 + tools/server/webui/src/lib/utils/agentic.ts | 38 +- tools/server/webui/src/lib/utils/base64.ts | 29 + tools/server/webui/src/lib/utils/index.ts | 3 + tools/server/webui/src/lib/utils/mcp.ts | 210 +- 44 files changed, 5225 insertions(+), 4477 deletions(-) create mode 100644 tools/server/webui/src/lib/clients/agentic.client.ts create mode 100644 tools/server/webui/src/lib/clients/chat.client.ts create mode 100644 tools/server/webui/src/lib/clients/conversations.client.ts create mode 100644 tools/server/webui/src/lib/clients/index.ts create mode 100644 tools/server/webui/src/lib/clients/mcp.client.ts delete mode 100644 tools/server/webui/src/lib/clients/openai-sse.ts delete mode 100644 tools/server/webui/src/lib/config/agentic.ts delete mode 100644 tools/server/webui/src/lib/config/mcp.ts delete mode 100644 tools/server/webui/src/lib/mcp/host-manager.ts delete mode 100644 tools/server/webui/src/lib/mcp/index.ts delete mode 100644 tools/server/webui/src/lib/mcp/server-connection.ts rename tools/server/webui/src/lib/services/{chat.ts => chat.service.ts} (92%) rename tools/server/webui/src/lib/services/{database.ts => database.service.ts} (85%) create mode 100644 tools/server/webui/src/lib/services/mcp.service.ts rename tools/server/webui/src/lib/services/{models.ts => models.service.ts} (70%) rename tools/server/webui/src/lib/services/{parameter-sync.spec.ts => parameter-sync.service.spec.ts} (98%) rename tools/server/webui/src/lib/services/{parameter-sync.ts => parameter-sync.service.ts} (81%) rename tools/server/webui/src/lib/services/{props.ts => props.service.ts} (82%) create mode 100644 tools/server/webui/src/lib/types/mcp.d.ts delete mode 100644 tools/server/webui/src/lib/types/mcp.ts create mode 100644 tools/server/webui/src/lib/utils/base64.ts diff --git a/tools/server/webui/src/lib/clients/agentic.client.ts b/tools/server/webui/src/lib/clients/agentic.client.ts new file mode 100644 index 0000000000..9a7df57897 --- /dev/null +++ b/tools/server/webui/src/lib/clients/agentic.client.ts @@ -0,0 +1,622 @@ +/** + * AgenticClient - Business Logic Facade for Agentic Loop Orchestration + * + * Coordinates the multi-turn agentic loop with MCP tools: + * - LLM streaming with tool call detection + * - Tool execution via MCPClient + * - Session state management + * - Turn limit enforcement + * + * **Architecture & Relationships:** + * - **AgenticClient** (this class): Orchestrates multi-turn tool loop + * - Uses MCPClient for tool execution + * - Uses ChatService for LLM streaming + * - Updates agenticStore with reactive state + * + * - **MCPClient**: Tool execution facade + * - **agenticStore**: Reactive state only ($state) + * + * **Key Features:** + * - Multi-turn tool call orchestration + * - Automatic routing of tool calls to appropriate MCP servers + * - Raw LLM output streaming (UI formatting is separate concern) + * - Lazy disconnect after flow completes + */ + +import { mcpClient } from '$lib/clients'; +import { ChatService } from '$lib/services'; +import { config } from '$lib/stores/settings.svelte'; +import { getAgenticConfig } from '$lib/utils/agentic'; +import { toAgenticMessages } from '$lib/utils'; +import type { AgenticMessage, AgenticToolCallList } from '$lib/types/agentic'; +import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api'; +import type { + ChatMessagePromptProgress, + ChatMessageTimings, + ChatMessageAgenticTimings, + ChatMessageToolCallTiming, + ChatMessageAgenticTurnStats +} from '$lib/types/chat'; +import type { MCPToolCall } from '$lib/types/mcp'; +import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '$lib/types/database'; + +export interface AgenticFlowCallbacks { + onChunk?: (chunk: string) => void; + onReasoningChunk?: (chunk: string) => void; + onToolCallChunk?: (serializedToolCalls: string) => void; + onModel?: (model: string) => void; + onComplete?: ( + content: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCalls?: string + ) => void; + onError?: (error: Error) => void; + onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; +} + +export interface AgenticFlowOptions { + stream?: boolean; + model?: string; + temperature?: number; + max_tokens?: number; + [key: string]: unknown; +} + +export interface AgenticFlowParams { + messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[]; + options?: AgenticFlowOptions; + callbacks: AgenticFlowCallbacks; + signal?: AbortSignal; + /** Per-chat MCP server overrides */ + perChatOverrides?: McpServerOverride[]; +} + +export interface AgenticFlowResult { + handled: boolean; + error?: Error; +} + +interface AgenticStoreStateCallbacks { + setRunning: (running: boolean) => void; + setCurrentTurn: (turn: number) => void; + setTotalToolCalls: (count: number) => void; + setLastError: (error: Error | null) => void; + setStreamingToolCall: (tc: { name: string; arguments: string } | null) => void; + clearStreamingToolCall: () => void; +} + +export class AgenticClient { + private storeCallbacks: AgenticStoreStateCallbacks | null = null; + + /** + * + * + * Store Integration + * + * + */ + + /** + * Sets callbacks for store state updates. + * Called by agenticStore during initialization. + */ + setStoreCallbacks(callbacks: AgenticStoreStateCallbacks): void { + this.storeCallbacks = callbacks; + } + + private get store(): AgenticStoreStateCallbacks { + if (!this.storeCallbacks) { + throw new Error('AgenticClient: Store callbacks not initialized'); + } + return this.storeCallbacks; + } + + /** + * + * + * Agentic Flow + * + * + */ + + /** + * Runs the agentic orchestration loop with MCP tools. + * Main entry point called by ChatClient when agentic mode is enabled. + * + * Coordinates: initial LLM request β†’ tool call detection β†’ tool execution β†’ loop until done. + * + * @param params - Flow parameters including messages, options, callbacks, and signal + * @returns Result indicating if the flow handled the request + */ + async runAgenticFlow(params: AgenticFlowParams): Promise { + const { messages, options = {}, callbacks, signal, perChatOverrides } = params; + const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } = + callbacks; + + // Get agentic configuration (considering per-chat MCP overrides) + const agenticConfig = getAgenticConfig(config(), perChatOverrides); + if (!agenticConfig.enabled) { + return { handled: false }; + } + + // Ensure MCP is initialized with per-chat overrides + const initialized = await mcpClient.ensureInitialized(perChatOverrides); + if (!initialized) { + console.log('[AgenticClient] MCP not initialized, falling back to standard chat'); + return { handled: false }; + } + + const tools = mcpClient.getToolDefinitionsForLLM(); + if (tools.length === 0) { + console.log('[AgenticClient] No tools available, falling back to standard chat'); + return { handled: false }; + } + + console.log(`[AgenticClient] Starting agentic flow with ${tools.length} tools`); + + const normalizedMessages: ApiChatMessageData[] = messages + .map((msg) => { + if ('id' in msg && 'convId' in msg && 'timestamp' in msg) { + // DatabaseMessage - use ChatService to convert + return ChatService.convertDbMessageToApiChatMessageData( + msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] } + ); + } + return msg as ApiChatMessageData; + }) + .filter((msg) => { + if (msg.role === 'system') { + const content = typeof msg.content === 'string' ? msg.content : ''; + return content.trim().length > 0; + } + return true; + }); + + this.store.setRunning(true); + this.store.setCurrentTurn(0); + this.store.setTotalToolCalls(0); + this.store.setLastError(null); + + try { + await this.executeAgenticLoop({ + messages: normalizedMessages, + options, + tools, + agenticConfig, + callbacks: { + onChunk, + onReasoningChunk, + onToolCallChunk, + onModel, + onComplete, + onError, + onTimings + }, + signal + }); + return { handled: true }; + } catch (error) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + this.store.setLastError(normalizedError); + onError?.(normalizedError); + return { handled: true, error: normalizedError }; + } finally { + this.store.setRunning(false); + // Lazy Disconnect: Close MCP connections after agentic flow completes + // This prevents continuous keepalive/heartbeat polling when tools are not in use + await mcpClient.shutdown().catch((err) => { + console.warn('[AgenticClient] Failed to shutdown MCP after flow:', err); + }); + + console.log('[AgenticClient] MCP connections closed (lazy disconnect)'); + } + } + + private async executeAgenticLoop(params: { + messages: ApiChatMessageData[]; + options: AgenticFlowOptions; + tools: ReturnType; + agenticConfig: ReturnType; + callbacks: AgenticFlowCallbacks; + signal?: AbortSignal; + }): Promise { + const { messages, options, tools, agenticConfig, callbacks, signal } = params; + const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onTimings } = + callbacks; + + const sessionMessages: AgenticMessage[] = toAgenticMessages(messages); + const allToolCalls: ApiChatCompletionToolCall[] = []; + let capturedTimings: ChatMessageTimings | undefined; + + const agenticTimings: ChatMessageAgenticTimings = { + turns: 0, + toolCallsCount: 0, + toolsMs: 0, + toolCalls: [], + perTurn: [], + llm: { + predicted_n: 0, + predicted_ms: 0, + prompt_n: 0, + prompt_ms: 0 + } + }; + + const maxTurns = agenticConfig.maxTurns; + const maxToolPreviewLines = agenticConfig.maxToolPreviewLines; + + for (let turn = 0; turn < maxTurns; turn++) { + this.store.setCurrentTurn(turn + 1); + agenticTimings.turns = turn + 1; + + if (signal?.aborted) { + onComplete?.( + '', + undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + return; + } + + // Filter reasoning content after first turn if configured + const shouldFilterReasoning = agenticConfig.filterReasoningAfterFirstTurn && turn > 0; + + let turnContent = ''; + let turnToolCalls: ApiChatCompletionToolCall[] = []; + let lastStreamingToolCallName = ''; + let lastStreamingToolCallArgsLength = 0; + + let turnTimings: ChatMessageTimings | undefined; + + const turnStats: ChatMessageAgenticTurnStats = { + turn: turn + 1, + llm: { + predicted_n: 0, + predicted_ms: 0, + prompt_n: 0, + prompt_ms: 0 + }, + toolCalls: [], + toolsMs: 0 + }; + + try { + await ChatService.sendMessage( + sessionMessages as ApiChatMessageData[], + { + ...options, + stream: true, + tools: tools.length > 0 ? tools : undefined, + onChunk: (chunk: string) => { + turnContent += chunk; + onChunk?.(chunk); + }, + onReasoningChunk: shouldFilterReasoning ? undefined : onReasoningChunk, + onToolCallChunk: (serialized: string) => { + try { + turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[]; + // Update store with streaming tool call state for UI visualization + // Only update when values actually change to avoid memory pressure + if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) { + const name = turnToolCalls[0].function.name || ''; + const args = turnToolCalls[0].function.arguments || ''; + // Only update if name changed or args grew significantly (every 100 chars) + const argsLengthBucket = Math.floor(args.length / 100); + if ( + name !== lastStreamingToolCallName || + argsLengthBucket !== lastStreamingToolCallArgsLength + ) { + lastStreamingToolCallName = name; + lastStreamingToolCallArgsLength = argsLengthBucket; + this.store.setStreamingToolCall({ name, arguments: args }); + } + } + } catch { + // Ignore parse errors during streaming + } + }, + onModel, + onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => { + onTimings?.(timings, progress); + if (timings) { + capturedTimings = timings; + turnTimings = timings; + } + }, + onComplete: () => { + // Completion handled after sendMessage resolves + }, + onError: (error: Error) => { + throw error; + } + }, + undefined, + signal + ); + + this.store.clearStreamingToolCall(); + + if (turnTimings) { + agenticTimings.llm.predicted_n += turnTimings.predicted_n || 0; + agenticTimings.llm.predicted_ms += turnTimings.predicted_ms || 0; + agenticTimings.llm.prompt_n += turnTimings.prompt_n || 0; + agenticTimings.llm.prompt_ms += turnTimings.prompt_ms || 0; + + turnStats.llm.predicted_n = turnTimings.predicted_n || 0; + turnStats.llm.predicted_ms = turnTimings.predicted_ms || 0; + turnStats.llm.prompt_n = turnTimings.prompt_n || 0; + turnStats.llm.prompt_ms = turnTimings.prompt_ms || 0; + } + } catch (error) { + if (signal?.aborted) { + onComplete?.( + '', + undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + return; + } + const normalizedError = error instanceof Error ? error : new Error('LLM stream error'); + onChunk?.(`\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`); + onComplete?.( + '', + undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + throw normalizedError; + } + + if (turnToolCalls.length === 0) { + onComplete?.( + '', + undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + return; + } + + const normalizedCalls = this.normalizeToolCalls(turnToolCalls); + if (normalizedCalls.length === 0) { + onComplete?.( + '', + undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + return; + } + + for (const call of normalizedCalls) { + allToolCalls.push({ + id: call.id, + type: call.type, + function: call.function ? { ...call.function } : undefined + }); + } + this.store.setTotalToolCalls(allToolCalls.length); + onToolCallChunk?.(JSON.stringify(allToolCalls)); + + sessionMessages.push({ + role: 'assistant', + content: turnContent || undefined, + tool_calls: normalizedCalls + }); + + for (const toolCall of normalizedCalls) { + if (signal?.aborted) { + onComplete?.( + '', + undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + return; + } + + // Start timing BEFORE emitToolCallStart to capture full perceived execution time + const toolStartTime = performance.now(); + this.emitToolCallStart(toolCall, onChunk); + + const mcpCall: MCPToolCall = { + id: toolCall.id, + function: { + name: toolCall.function.name, + arguments: toolCall.function.arguments + } + }; + + let result: string; + let toolSuccess = true; + + try { + const executionResult = await mcpClient.executeTool(mcpCall, signal); + result = executionResult.content; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + onComplete?.( + '', + undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + return; + } + result = `Error: ${error instanceof Error ? error.message : String(error)}`; + toolSuccess = false; + } + + const toolDurationMs = performance.now() - toolStartTime; + + const toolTiming: ChatMessageToolCallTiming = { + name: toolCall.function.name, + duration_ms: Math.round(toolDurationMs), + success: toolSuccess + }; + + agenticTimings.toolCalls!.push(toolTiming); + agenticTimings.toolCallsCount++; + agenticTimings.toolsMs += Math.round(toolDurationMs); + + turnStats.toolCalls.push(toolTiming); + turnStats.toolsMs += Math.round(toolDurationMs); + + if (signal?.aborted) { + onComplete?.( + '', + undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + return; + } + + this.emitToolCallResult(result, maxToolPreviewLines, onChunk); + + // Add tool result to session (sanitize base64 images for context) + const contextValue = this.isBase64Image(result) ? '[Image displayed to user]' : result; + sessionMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: contextValue + }); + } + + // Save per-turn stats (only if there were tool calls in this turn) + if (turnStats.toolCalls.length > 0) { + agenticTimings.perTurn!.push(turnStats); + } + } + + onChunk?.('\n\n```\nTurn limit reached\n```\n'); + onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined); + } + + /** + * + * + * Timing & Statistics + * + * + */ + + /** + * Builds final timings object with agentic stats. + * Single-turn flows return original timings; multi-turn includes aggregated stats. + */ + private buildFinalTimings( + capturedTimings: ChatMessageTimings | undefined, + agenticTimings: ChatMessageAgenticTimings + ): ChatMessageTimings | undefined { + // If no tool calls were made, this was effectively a single-turn flow + // Return the original timings without agentic data + if (agenticTimings.toolCallsCount === 0) { + return capturedTimings; + } + + const finalTimings: ChatMessageTimings = { + // Use the last turn's values as the "current" values for backward compatibility + predicted_n: capturedTimings?.predicted_n, + predicted_ms: capturedTimings?.predicted_ms, + prompt_n: capturedTimings?.prompt_n, + prompt_ms: capturedTimings?.prompt_ms, + cache_n: capturedTimings?.cache_n, + agentic: agenticTimings + }; + + return finalTimings; + } + + /** + * + * + * Tool Call Processing + * + * + */ + + private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList { + if (!toolCalls) return []; + return toolCalls.map((call, index) => ({ + id: call?.id ?? `tool_${index}`, + type: (call?.type as 'function') ?? 'function', + function: { + name: call?.function?.name ?? '', + arguments: call?.function?.arguments ?? '' + } + })); + } + + /** + * Emit tool call start marker (shows "pending" state in UI). + */ + private emitToolCallStart( + toolCall: AgenticToolCallList[number], + emit?: (chunk: string) => void + ): void { + if (!emit) return; + + const toolName = toolCall.function.name; + const toolArgs = toolCall.function.arguments; + // Base64 encode args to avoid conflicts with markdown/HTML parsing + const toolArgsBase64 = btoa(unescape(encodeURIComponent(toolArgs))); + + let output = `\n\n<<>>`; + output += `\n<<>>`; + output += `\n<<>>`; + emit(output); + } + + /** + * Emit tool call result and end marker. + */ + private emitToolCallResult( + result: string, + maxLines: number, + emit?: (chunk: string) => void + ): void { + if (!emit) return; + + let output = ''; + if (this.isBase64Image(result)) { + output += `\n![tool-result](${result.trim()})`; + } else { + // Don't wrap in code fences - result may already be markdown with its own code blocks + const lines = result.split('\n'); + const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines; + output += `\n${trimmedLines.join('\n')}`; + } + + output += `\n<<>>\n`; + emit(output); + } + + /** + * + * + * Utilities + * + * + */ + + private isBase64Image(content: string): boolean { + const trimmed = content.trim(); + if (!trimmed.startsWith('data:image/')) return false; + + const match = trimmed.match(/^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+=*)$/); + if (!match) return false; + + const base64Payload = match[2]; + return base64Payload.length > 0 && base64Payload.length % 4 === 0; + } + + clearError(): void { + this.store.setLastError(null); + } +} + +export const agenticClient = new AgenticClient(); diff --git a/tools/server/webui/src/lib/clients/chat.client.ts b/tools/server/webui/src/lib/clients/chat.client.ts new file mode 100644 index 0000000000..0608974610 --- /dev/null +++ b/tools/server/webui/src/lib/clients/chat.client.ts @@ -0,0 +1,1585 @@ +import { DatabaseService, ChatService } from '$lib/services'; +import { conversationsStore } from '$lib/stores/conversations.svelte'; +import { config } from '$lib/stores/settings.svelte'; +import { agenticClient } from '$lib/clients'; +import { contextSize, isRouterMode } from '$lib/stores/server.svelte'; +import { + selectedModelName, + modelsStore, + selectedModelContextSize +} from '$lib/stores/models.svelte'; +import { + normalizeModelName, + filterByLeafNodeId, + findDescendantMessages, + findLeafNode +} from '$lib/utils'; +import { DEFAULT_CONTEXT } from '$lib/constants/default-context'; +import { getAgenticConfig } from '$lib/utils/agentic'; +import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui'; +import type { ChatMessageTimings, ChatMessagePromptProgress } from '$lib/types/chat'; +import type { DatabaseMessage, DatabaseMessageExtra } from '$lib/types/database'; + +export interface ApiProcessingState { + status: 'idle' | 'preparing' | 'generating'; + tokensDecoded: number; + tokensRemaining: number; + contextUsed: number; + contextTotal: number; + outputTokensUsed: number; + outputTokensMax: number; + hasNextToken: boolean; + tokensPerSecond: number; + temperature: number; + topP: number; + speculative: boolean; + progressPercent?: number; + promptProgress?: { + total: number; + cache: number; + processed: number; + time_ms: number; + }; + promptTokens: number; + promptMs?: number; + cacheTokens: number; +} + +export interface ErrorDialogState { + type: 'timeout' | 'server'; + message: string; + contextInfo?: { n_prompt_tokens: number; n_ctx: number }; +} + +export interface ChatStreamCallbacks { + onChunk?: (chunk: string) => void; + onReasoningChunk?: (chunk: string) => void; + onToolCallChunk?: (chunk: string) => void; + onModel?: (model: string) => void; + onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; + onComplete?: ( + content?: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCallContent?: string + ) => void; + onError?: (error: Error) => void; +} + +type ChatRole = 'user' | 'assistant' | 'system' | 'tool'; +type ChatMessageType = 'text' | 'root'; + +interface ChatStoreStateCallbacks { + setChatLoading: (convId: string, loading: boolean) => void; + setChatStreaming: (convId: string, response: string, messageId: string) => void; + clearChatStreaming: (convId: string) => void; + getChatStreaming: (convId: string) => { response: string; messageId: string } | undefined; + setProcessingState: (convId: string, state: ApiProcessingState | null) => void; + getProcessingState: (convId: string) => ApiProcessingState | null; + setActiveProcessingConversation: (convId: string | null) => void; + setStreamingActive: (active: boolean) => void; + showErrorDialog: (state: ErrorDialogState | null) => void; + getAbortController: (convId: string) => AbortController; + abortRequest: (convId?: string) => void; + setPendingEditMessageId: (messageId: string | null) => void; + getActiveConversationId: () => string | null; + getCurrentResponse: () => string; +} + +/** + * ChatClient - Business Logic Facade for Chat Operations + * + * Coordinates AI interactions, message operations, and streaming orchestration. + * + * **Architecture & Relationships:** + * - **ChatClient** (this class): Business logic facade + * - Uses ChatService for low-level API operations + * - Updates chatStore with reactive state + * - Coordinates with conversationsStore for persistence + * - Handles streaming, branching, and error recovery + * + * - **ChatService**: Stateless API layer (sendMessage, streaming) + * - **chatStore**: Reactive state only ($state, getters, setters) + * + * **Key Responsibilities:** + * - Message lifecycle (send, edit, delete, branch) + * - AI streaming orchestration + * - Processing state management (timing, context info) + * - Error handling (timeout, server errors) + * - Graceful stop with partial response saving + */ +export class ChatClient { + private storeCallbacks: ChatStoreStateCallbacks | null = null; + + /** + * + * + * Store Integration + * + * + */ + + /** + * Sets callbacks for store state updates. + * Called by chatStore during initialization to establish bidirectional communication. + */ + setStoreCallbacks(callbacks: ChatStoreStateCallbacks): void { + this.storeCallbacks = callbacks; + } + + private get store(): ChatStoreStateCallbacks { + if (!this.storeCallbacks) { + throw new Error('ChatClient: Store callbacks not initialized'); + } + return this.storeCallbacks; + } + + /** + * + * + * Message Operations + * + * + */ + + private getMessageByIdWithRole( + messageId: string, + expectedRole?: ChatRole + ): { message: DatabaseMessage; index: number } | null { + const index = conversationsStore.findMessageIndex(messageId); + if (index === -1) return null; + + const message = conversationsStore.activeMessages[index]; + if (expectedRole && message.role !== expectedRole) return null; + + return { message, index }; + } + + /** + * Adds a new message to the active conversation. + * @param role - Message role (user, assistant, system, tool) + * @param content - Message text content + * @param type - Message type (text or root) + * @param parent - Parent message ID, or '-1' to append to conversation end + * @param extras - Optional attachments (images, files, etc.) + * @returns The created message or null if failed + */ + async addMessage( + role: ChatRole, + content: string, + type: ChatMessageType = 'text', + parent: string = '-1', + extras?: DatabaseMessageExtra[] + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) { + console.error('No active conversation when trying to add message'); + return null; + } + + try { + let parentId: string | null = null; + + if (parent === '-1') { + const activeMessages = conversationsStore.activeMessages; + if (activeMessages.length > 0) { + parentId = activeMessages[activeMessages.length - 1].id; + } else { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root'); + if (!rootMessage) { + parentId = await DatabaseService.createRootMessage(activeConv.id); + } else { + parentId = rootMessage.id; + } + } + } else { + parentId = parent; + } + + const message = await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + role, + content, + type, + timestamp: Date.now(), + thinking: '', + toolCalls: '', + children: [], + extra: extras + }, + parentId + ); + + conversationsStore.addMessageToActive(message); + await conversationsStore.updateCurrentNode(message.id); + conversationsStore.updateConversationTimestamp(); + + return message; + } catch (error) { + console.error('Failed to add message:', error); + return null; + } + } + + /** + * Adds a system message placeholder at the conversation start. + * Triggers edit mode to allow user to customize the system prompt. + * If conversation doesn't exist, creates one first. + */ + async addSystemPrompt(): Promise { + let activeConv = conversationsStore.activeConversation; + + if (!activeConv) { + await conversationsStore.createConversation(); + activeConv = conversationsStore.activeConversation; + } + if (!activeConv) return; + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + let rootId: string; + + if (!rootMessage) { + rootId = await DatabaseService.createRootMessage(activeConv.id); + } else { + rootId = rootMessage.id; + } + + const existingSystemMessage = allMessages.find( + (m) => m.role === 'system' && m.parent === rootId + ); + + if (existingSystemMessage) { + this.store.setPendingEditMessageId(existingSystemMessage.id); + + if (!conversationsStore.activeMessages.some((m) => m.id === existingSystemMessage.id)) { + conversationsStore.activeMessages.unshift(existingSystemMessage); + } + return; + } + + const activeMessages = conversationsStore.activeMessages; + const firstActiveMessage = activeMessages.find((m) => m.parent === rootId); + + const systemMessage = await DatabaseService.createSystemMessage( + activeConv.id, + SYSTEM_MESSAGE_PLACEHOLDER, + rootId + ); + + if (firstActiveMessage) { + await DatabaseService.updateMessage(firstActiveMessage.id, { + parent: systemMessage.id + }); + + await DatabaseService.updateMessage(systemMessage.id, { + children: [firstActiveMessage.id] + }); + + const updatedRootChildren = rootMessage + ? rootMessage.children.filter((id: string) => id !== firstActiveMessage.id) + : []; + await DatabaseService.updateMessage(rootId, { + children: [ + ...updatedRootChildren.filter((id: string) => id !== systemMessage.id), + systemMessage.id + ] + }); + + const firstMsgIndex = conversationsStore.findMessageIndex(firstActiveMessage.id); + if (firstMsgIndex !== -1) { + conversationsStore.updateMessageAtIndex(firstMsgIndex, { parent: systemMessage.id }); + } + } + + conversationsStore.activeMessages.unshift(systemMessage); + this.store.setPendingEditMessageId(systemMessage.id); + conversationsStore.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to add system prompt:', error); + } + } + + /** + * Removes a system message placeholder without deleting its children. + * @returns true if the entire conversation was deleted, false otherwise + */ + async removeSystemPromptPlaceholder(messageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return false; + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const systemMessage = allMessages.find((m) => m.id === messageId); + if (!systemMessage || systemMessage.role !== 'system') return false; + + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + if (!rootMessage) return false; + + const isEmptyConversation = allMessages.length === 2 && systemMessage.children.length === 0; + + if (isEmptyConversation) { + await conversationsStore.deleteConversation(activeConv.id); + return true; + } + + for (const childId of systemMessage.children) { + await DatabaseService.updateMessage(childId, { parent: rootMessage.id }); + + const childIndex = conversationsStore.findMessageIndex(childId); + if (childIndex !== -1) { + conversationsStore.updateMessageAtIndex(childIndex, { parent: rootMessage.id }); + } + } + + const newRootChildren = [ + ...rootMessage.children.filter((id: string) => id !== messageId), + ...systemMessage.children + ]; + + await DatabaseService.updateMessage(rootMessage.id, { children: newRootChildren }); + await DatabaseService.deleteMessage(messageId); + + const systemIndex = conversationsStore.findMessageIndex(messageId); + if (systemIndex !== -1) { + conversationsStore.activeMessages.splice(systemIndex, 1); + } + + conversationsStore.updateConversationTimestamp(); + + return false; + } catch (error) { + console.error('Failed to remove system prompt placeholder:', error); + return false; + } + } + + /** + * + * + * Message Sending & Streaming + * + * + */ + + private async createAssistantMessage(parentId?: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return null; + + return await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + type: 'text', + role: 'assistant', + content: '', + timestamp: Date.now(), + thinking: '', + toolCalls: '', + children: [], + model: null + }, + parentId || null + ); + } + + /** + * Sends a user message and triggers AI response generation. + * Creates conversation if none exists, handles system prompt injection, + * and orchestrates the streaming response flow. + * @param content - User message text + * @param extras - Optional attachments + */ + async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise { + if (!content.trim() && (!extras || extras.length === 0)) return; + const activeConv = conversationsStore.activeConversation; + if (activeConv && this.isChatLoading(activeConv.id)) return; + + let isNewConversation = false; + if (!activeConv) { + await conversationsStore.createConversation(); + isNewConversation = true; + } + const currentConv = conversationsStore.activeConversation; + if (!currentConv) return; + + this.store.showErrorDialog(null); + this.store.setChatLoading(currentConv.id, true); + this.store.clearChatStreaming(currentConv.id); + + try { + if (isNewConversation) { + const rootId = await DatabaseService.createRootMessage(currentConv.id); + const currentConfig = config(); + const systemPrompt = currentConfig.systemMessage?.toString().trim(); + + if (systemPrompt) { + const systemMessage = await DatabaseService.createSystemMessage( + currentConv.id, + systemPrompt, + rootId + ); + conversationsStore.addMessageToActive(systemMessage); + } + } + + const userMessage = await this.addMessage('user', content, 'text', '-1', extras); + if (!userMessage) throw new Error('Failed to add user message'); + if (isNewConversation && content) + await conversationsStore.updateConversationName(currentConv.id, content.trim()); + + const assistantMessage = await this.createAssistantMessage(userMessage.id); + if (!assistantMessage) throw new Error('Failed to create assistant message'); + + conversationsStore.addMessageToActive(assistantMessage); + await this.streamChatCompletion( + conversationsStore.activeMessages.slice(0, -1), + assistantMessage + ); + } catch (error) { + if (this.isAbortError(error)) { + this.store.setChatLoading(currentConv.id, false); + return; + } + console.error('Failed to send message:', error); + this.store.setChatLoading(currentConv.id, false); + + const dialogType = + error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'server'; + const contextInfo = ( + error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } + ).contextInfo; + + this.store.showErrorDialog({ + type: dialogType, + message: error instanceof Error ? error.message : 'Unknown error', + contextInfo + }); + } + } + + private async streamChatCompletion( + allMessages: DatabaseMessage[], + assistantMessage: DatabaseMessage, + onComplete?: (content: string) => Promise, + onError?: (error: Error) => void, + modelOverride?: string | null + ): Promise { + if (isRouterMode()) { + const modelName = modelOverride || selectedModelName(); + if (modelName && !modelsStore.getModelProps(modelName)) { + await modelsStore.fetchModelProps(modelName); + } + } + + let streamedContent = ''; + let streamedReasoningContent = ''; + let streamedToolCallContent = ''; + let resolvedModel: string | null = null; + let modelPersisted = false; + + const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => { + if (!modelName) return; + const normalizedModel = normalizeModelName(modelName); + if (!normalizedModel || normalizedModel === resolvedModel) return; + resolvedModel = normalizedModel; + const messageIndex = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(messageIndex, { model: normalizedModel }); + if (persistImmediately && !modelPersisted) { + modelPersisted = true; + DatabaseService.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(() => { + modelPersisted = false; + resolvedModel = null; + }); + } + }; + + this.store.setStreamingActive(true); + this.store.setActiveProcessingConversation(assistantMessage.convId); + + const abortController = this.store.getAbortController(assistantMessage.convId); + + const streamCallbacks: ChatStreamCallbacks = { + onChunk: (chunk: string) => { + streamedContent += chunk; + this.store.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id); + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); + }, + onReasoningChunk: (reasoningChunk: string) => { + streamedReasoningContent += reasoningChunk; + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { thinking: streamedReasoningContent }); + }, + onToolCallChunk: (toolCallChunk: string) => { + const chunk = toolCallChunk.trim(); + if (!chunk) return; + streamedToolCallContent = chunk; + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent }); + }, + onModel: (modelName: string) => recordModel(modelName), + onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { + const tokensPerSecond = + timings?.predicted_ms && timings?.predicted_n + ? (timings.predicted_n / timings.predicted_ms) * 1000 + : 0; + this.updateProcessingStateFromTimings( + { + prompt_n: timings?.prompt_n || 0, + prompt_ms: timings?.prompt_ms, + predicted_n: timings?.predicted_n || 0, + predicted_per_second: tokensPerSecond, + cache_n: timings?.cache_n || 0, + prompt_progress: promptProgress + }, + assistantMessage.convId + ); + }, + onComplete: async ( + finalContent?: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCallContent?: string + ) => { + this.store.setStreamingActive(false); + + const updateData: Record = { + content: finalContent || streamedContent, + thinking: reasoningContent || streamedReasoningContent, + toolCalls: toolCallContent || streamedToolCallContent, + timings + }; + if (resolvedModel && !modelPersisted) { + updateData.model = resolvedModel; + } + await DatabaseService.updateMessage(assistantMessage.id, updateData); + + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + const uiUpdate: Partial = { + content: updateData.content as string, + toolCalls: updateData.toolCalls as string + }; + if (timings) uiUpdate.timings = timings; + if (resolvedModel) uiUpdate.model = resolvedModel; + + conversationsStore.updateMessageAtIndex(idx, uiUpdate); + await conversationsStore.updateCurrentNode(assistantMessage.id); + + if (onComplete) await onComplete(streamedContent); + this.store.setChatLoading(assistantMessage.convId, false); + this.store.clearChatStreaming(assistantMessage.convId); + this.store.setProcessingState(assistantMessage.convId, null); + + if (isRouterMode()) { + modelsStore.fetchRouterModels().catch(console.error); + } + }, + onError: (error: Error) => { + this.store.setStreamingActive(false); + + if (this.isAbortError(error)) { + this.store.setChatLoading(assistantMessage.convId, false); + this.store.clearChatStreaming(assistantMessage.convId); + this.store.setProcessingState(assistantMessage.convId, null); + return; + } + + console.error('Streaming error:', error); + + this.store.setChatLoading(assistantMessage.convId, false); + this.store.clearChatStreaming(assistantMessage.convId); + this.store.setProcessingState(assistantMessage.convId, null); + + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + + if (idx !== -1) { + const failedMessage = conversationsStore.removeMessageAtIndex(idx); + if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error); + } + + const contextInfo = ( + error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } + ).contextInfo; + + this.store.showErrorDialog({ + type: error.name === 'TimeoutError' ? 'timeout' : 'server', + message: error.message, + contextInfo + }); + + if (onError) onError(error); + } + }; + + const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides; + const agenticConfig = getAgenticConfig(config(), perChatOverrides); + if (agenticConfig.enabled) { + const agenticResult = await agenticClient.runAgenticFlow({ + messages: allMessages, + options: { + ...this.getApiOptions(), + ...(modelOverride ? { model: modelOverride } : {}) + }, + callbacks: streamCallbacks, + signal: abortController.signal, + perChatOverrides + }); + + if (agenticResult.handled) { + return; + } + } + + await ChatService.sendMessage( + allMessages, + { + ...this.getApiOptions(), + ...(modelOverride ? { model: modelOverride } : {}), + ...streamCallbacks + }, + assistantMessage.convId, + abortController.signal + ); + } + + /** + * + * + * Generation Control + * + * + */ + + /** + * Stops generation for the active conversation. + * Saves any partial response before aborting. + */ + async stopGeneration(): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + await this.stopGenerationForChat(activeConv.id); + } + + /** + * Stops generation for a specific conversation. + * @param convId - Conversation ID to stop + */ + async stopGenerationForChat(convId: string): Promise { + await this.savePartialResponseIfNeeded(convId); + + this.store.setStreamingActive(false); + this.store.abortRequest(convId); + this.store.setChatLoading(convId, false); + this.store.clearChatStreaming(convId); + this.store.setProcessingState(convId, null); + } + + private async savePartialResponseIfNeeded(convId?: string): Promise { + const conversationId = convId || conversationsStore.activeConversation?.id; + if (!conversationId) return; + + const streamingState = this.store.getChatStreaming(conversationId); + if (!streamingState || !streamingState.response.trim()) return; + + const messages = + conversationId === conversationsStore.activeConversation?.id + ? conversationsStore.activeMessages + : await conversationsStore.getConversationMessages(conversationId); + + if (!messages.length) return; + + const lastMessage = messages[messages.length - 1]; + + if (lastMessage?.role === 'assistant') { + try { + const updateData: { content: string; thinking?: string; timings?: ChatMessageTimings } = { + content: streamingState.response + }; + if (lastMessage.thinking?.trim()) updateData.thinking = lastMessage.thinking; + const lastKnownState = this.store.getProcessingState(conversationId); + if (lastKnownState) { + updateData.timings = { + prompt_n: lastKnownState.promptTokens || 0, + prompt_ms: lastKnownState.promptMs, + predicted_n: lastKnownState.tokensDecoded || 0, + cache_n: lastKnownState.cacheTokens || 0, + predicted_ms: + lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded + ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000 + : undefined + }; + } + + await DatabaseService.updateMessage(lastMessage.id, updateData); + + lastMessage.content = this.store.getCurrentResponse(); + + if (updateData.thinking) lastMessage.thinking = updateData.thinking; + if (updateData.timings) lastMessage.timings = updateData.timings; + } catch (error) { + lastMessage.content = this.store.getCurrentResponse(); + console.error('Failed to save partial response:', error); + } + } + } + + /** + * + * + * Message Editing + * + * + */ + + /** + * Updates a user message content and regenerates the AI response. + * Deletes all messages after the edited one before regenerating. + * @param messageId - ID of the user message to update + * @param newContent - New message content + */ + async updateMessage(messageId: string, newContent: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + if (this.isChatLoading(activeConv.id)) await this.stopGeneration(); + + const result = this.getMessageByIdWithRole(messageId, 'user'); + if (!result) return; + const { message: messageToUpdate, index: messageIndex } = result; + const originalContent = messageToUpdate.content; + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id; + + conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent }); + await DatabaseService.updateMessage(messageId, { content: newContent }); + + if (isFirstUserMessage && newContent.trim()) { + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + newContent.trim() + ); + } + + const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1); + + for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); + + conversationsStore.sliceActiveMessages(messageIndex + 1); + conversationsStore.updateConversationTimestamp(); + + this.store.setChatLoading(activeConv.id, true); + this.store.clearChatStreaming(activeConv.id); + + const assistantMessage = await this.createAssistantMessage(); + if (!assistantMessage) throw new Error('Failed to create assistant message'); + + conversationsStore.addMessageToActive(assistantMessage); + + await conversationsStore.updateCurrentNode(assistantMessage.id); + await this.streamChatCompletion( + conversationsStore.activeMessages.slice(0, -1), + assistantMessage, + undefined, + () => { + conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), { + content: originalContent + }); + } + ); + } catch (error) { + if (!this.isAbortError(error)) console.error('Failed to update message:', error); + } + } + + /** + * + * + * Message Regeneration + * + * + */ + + /** + * Regenerates an assistant message by deleting it and all following messages, + * then generating a new response. + * @param messageId - ID of the assistant message to regenerate + */ + async regenerateMessage(messageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoading(activeConv.id)) return; + + const result = this.getMessageByIdWithRole(messageId, 'assistant'); + if (!result) return; + const { index: messageIndex } = result; + + try { + const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex); + for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); + conversationsStore.sliceActiveMessages(messageIndex); + conversationsStore.updateConversationTimestamp(); + + this.store.setChatLoading(activeConv.id, true); + this.store.clearChatStreaming(activeConv.id); + + const parentMessageId = + conversationsStore.activeMessages.length > 0 + ? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id + : undefined; + const assistantMessage = await this.createAssistantMessage(parentMessageId); + if (!assistantMessage) throw new Error('Failed to create assistant message'); + conversationsStore.addMessageToActive(assistantMessage); + await this.streamChatCompletion( + conversationsStore.activeMessages.slice(0, -1), + assistantMessage + ); + } catch (error) { + if (!this.isAbortError(error)) console.error('Failed to regenerate message:', error); + this.store.setChatLoading(activeConv?.id || '', false); + } + } + + /** + * Regenerates an assistant message as a new branch. + * Creates a sibling message instead of replacing the original. + * @param messageId - ID of the assistant message to regenerate + * @param modelOverride - Optional model to use instead of default + */ + async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoading(activeConv.id)) return; + try { + const idx = conversationsStore.findMessageIndex(messageId); + if (idx === -1) return; + const msg = conversationsStore.activeMessages[idx]; + if (msg.role !== 'assistant') return; + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const parentMessage = allMessages.find((m) => m.id === msg.parent); + if (!parentMessage) return; + + this.store.setChatLoading(activeConv.id, true); + this.store.clearChatStreaming(activeConv.id); + + const newAssistantMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: '', + thinking: '', + toolCalls: '', + children: [], + model: null + }, + parentMessage.id + ); + await conversationsStore.updateCurrentNode(newAssistantMessage.id); + conversationsStore.updateConversationTimestamp(); + await conversationsStore.refreshActiveMessages(); + + const conversationPath = filterByLeafNodeId( + allMessages, + parentMessage.id, + false + ) as DatabaseMessage[]; + const modelToUse = modelOverride || msg.model || undefined; + await this.streamChatCompletion( + conversationPath, + newAssistantMessage, + undefined, + undefined, + modelToUse + ); + } catch (error) { + if (!this.isAbortError(error)) + console.error('Failed to regenerate message with branching:', error); + this.store.setChatLoading(activeConv?.id || '', false); + } + } + + /** + * + * + * Message Deletion + * + * + */ + + /** + * Gets information about messages that would be deleted. + * Includes the target message and all its descendants. + * @param messageId - ID of the message to analyze + * @returns Deletion stats including counts by role + */ + async getDeletionInfo(messageId: string): Promise<{ + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; + }> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) + return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] }; + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const descendants = findDescendantMessages(allMessages, messageId); + const allToDelete = [messageId, ...descendants]; + const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id)); + let userMessages = 0, + assistantMessages = 0; + const messageTypes: string[] = []; + for (const msg of messagesToDelete) { + if (msg.role === 'user') { + userMessages++; + if (!messageTypes.includes('user message')) messageTypes.push('user message'); + } else if (msg.role === 'assistant') { + assistantMessages++; + if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response'); + } + } + return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes }; + } + + /** + * Deletes a message and all its descendants. + * Handles branch navigation if deleted message is in current path. + * @param messageId - ID of the message to delete + */ + async deleteMessage(messageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const messageToDelete = allMessages.find((m) => m.id === messageId); + if (!messageToDelete) return; + + const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false); + const isInCurrentPath = currentPath.some((m) => m.id === messageId); + + if (isInCurrentPath && messageToDelete.parent) { + const siblings = allMessages.filter( + (m) => m.parent === messageToDelete.parent && m.id !== messageId + ); + + if (siblings.length > 0) { + const latestSibling = siblings.reduce((latest, sibling) => + sibling.timestamp > latest.timestamp ? sibling : latest + ); + await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id)); + } else if (messageToDelete.parent) { + await conversationsStore.updateCurrentNode( + findLeafNode(allMessages, messageToDelete.parent) + ); + } + } + await DatabaseService.deleteMessageCascading(activeConv.id, messageId); + await conversationsStore.refreshActiveMessages(); + + conversationsStore.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to delete message:', error); + } + } + + /** + * + * + * Continue Generation + * + * + */ + + /** + * Continues generating content for an existing assistant message. + * Appends new content to the existing message. + * @param messageId - ID of the assistant message to continue + */ + async continueAssistantMessage(messageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoading(activeConv.id)) return; + + const result = this.getMessageByIdWithRole(messageId, 'assistant'); + if (!result) return; + const { message: msg, index: idx } = result; + + try { + this.store.showErrorDialog(null); + this.store.setChatLoading(activeConv.id, true); + this.store.clearChatStreaming(activeConv.id); + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const dbMessage = allMessages.find((m) => m.id === messageId); + + if (!dbMessage) { + this.store.setChatLoading(activeConv.id, false); + return; + } + + const originalContent = dbMessage.content; + const originalThinking = dbMessage.thinking || ''; + + const conversationContext = conversationsStore.activeMessages.slice(0, idx); + const contextWithContinue = [ + ...conversationContext, + { role: 'assistant' as const, content: originalContent } + ]; + + let appendedContent = '', + appendedThinking = '', + hasReceivedContent = false; + + const abortController = this.store.getAbortController(msg.convId); + + await ChatService.sendMessage( + contextWithContinue, + { + ...this.getApiOptions(), + + onChunk: (chunk: string) => { + hasReceivedContent = true; + appendedContent += chunk; + const fullContent = originalContent + appendedContent; + this.store.setChatStreaming(msg.convId, fullContent, msg.id); + conversationsStore.updateMessageAtIndex(idx, { content: fullContent }); + }, + + onReasoningChunk: (reasoningChunk: string) => { + hasReceivedContent = true; + appendedThinking += reasoningChunk; + conversationsStore.updateMessageAtIndex(idx, { + thinking: originalThinking + appendedThinking + }); + }, + + onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { + const tokensPerSecond = + timings?.predicted_ms && timings?.predicted_n + ? (timings.predicted_n / timings.predicted_ms) * 1000 + : 0; + this.updateProcessingStateFromTimings( + { + prompt_n: timings?.prompt_n || 0, + prompt_ms: timings?.prompt_ms, + predicted_n: timings?.predicted_n || 0, + predicted_per_second: tokensPerSecond, + cache_n: timings?.cache_n || 0, + prompt_progress: promptProgress + }, + msg.convId + ); + }, + + onComplete: async ( + finalContent?: string, + reasoningContent?: string, + timings?: ChatMessageTimings + ) => { + const fullContent = originalContent + (finalContent || appendedContent); + const fullThinking = originalThinking + (reasoningContent || appendedThinking); + await DatabaseService.updateMessage(msg.id, { + content: fullContent, + thinking: fullThinking, + timestamp: Date.now(), + timings + }); + conversationsStore.updateMessageAtIndex(idx, { + content: fullContent, + thinking: fullThinking, + timestamp: Date.now(), + timings + }); + conversationsStore.updateConversationTimestamp(); + this.store.setChatLoading(msg.convId, false); + this.store.clearChatStreaming(msg.convId); + this.store.setProcessingState(msg.convId, null); + }, + + onError: async (error: Error) => { + if (this.isAbortError(error)) { + if (hasReceivedContent && appendedContent) { + await DatabaseService.updateMessage(msg.id, { + content: originalContent + appendedContent, + thinking: originalThinking + appendedThinking, + timestamp: Date.now() + }); + conversationsStore.updateMessageAtIndex(idx, { + content: originalContent + appendedContent, + thinking: originalThinking + appendedThinking, + timestamp: Date.now() + }); + } + this.store.setChatLoading(msg.convId, false); + this.store.clearChatStreaming(msg.convId); + this.store.setProcessingState(msg.convId, null); + return; + } + console.error('Continue generation error:', error); + conversationsStore.updateMessageAtIndex(idx, { + content: originalContent, + thinking: originalThinking + }); + await DatabaseService.updateMessage(msg.id, { + content: originalContent, + thinking: originalThinking + }); + this.store.setChatLoading(msg.convId, false); + this.store.clearChatStreaming(msg.convId); + this.store.setProcessingState(msg.convId, null); + this.store.showErrorDialog({ + type: error.name === 'TimeoutError' ? 'timeout' : 'server', + message: error.message + }); + } + }, + msg.convId, + abortController.signal + ); + } catch (error) { + if (!this.isAbortError(error)) console.error('Failed to continue message:', error); + if (activeConv) this.store.setChatLoading(activeConv.id, false); + } + } + + /** + * Edits an assistant message content. + * Can either replace in-place or create a new branch. + * @param messageId - ID of the assistant message to edit + * @param newContent - New message content + * @param shouldBranch - If true, creates a sibling; if false, replaces in-place + */ + async editAssistantMessage( + messageId: string, + newContent: string, + shouldBranch: boolean + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoading(activeConv.id)) return; + + const result = this.getMessageByIdWithRole(messageId, 'assistant'); + if (!result) return; + const { message: msg, index: idx } = result; + + try { + if (shouldBranch) { + const newMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: newContent, + thinking: msg.thinking || '', + toolCalls: msg.toolCalls || '', + children: [], + model: msg.model + }, + msg.parent! + ); + await conversationsStore.updateCurrentNode(newMessage.id); + } else { + await DatabaseService.updateMessage(msg.id, { content: newContent }); + await conversationsStore.updateCurrentNode(msg.id); + conversationsStore.updateMessageAtIndex(idx, { + content: newContent + }); + } + conversationsStore.updateConversationTimestamp(); + await conversationsStore.refreshActiveMessages(); + } catch (error) { + console.error('Failed to edit assistant message:', error); + } + } + + /** + * Edits a user message without regenerating responses. + * Preserves all child messages (assistant responses). + * @param messageId - ID of the user message to edit + * @param newContent - New message content + * @param newExtras - Optional new attachments + */ + async editUserMessagePreserveResponses( + messageId: string, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + + const result = this.getMessageByIdWithRole(messageId, 'user'); + if (!result) return; + const { message: msg, index: idx } = result; + + try { + const updateData: Partial = { + content: newContent + }; + + if (newExtras !== undefined) { + updateData.extra = JSON.parse(JSON.stringify(newExtras)); + } + + await DatabaseService.updateMessage(messageId, updateData); + conversationsStore.updateMessageAtIndex(idx, updateData); + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + + if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) { + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + newContent.trim() + ); + } + conversationsStore.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to edit user message:', error); + } + } + + /** + * Edits a user or system message by creating a new branch. + * For user messages, also generates a new AI response. + * @param messageId - ID of the message to edit + * @param newContent - New message content + * @param newExtras - Optional new attachments + */ + async editMessageWithBranching( + messageId: string, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoading(activeConv.id)) return; + + let result = this.getMessageByIdWithRole(messageId, 'user'); + + if (!result) { + result = this.getMessageByIdWithRole(messageId, 'system'); + } + + if (!result) return; + const { message: msg } = result; + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const isFirstUserMessage = + msg.role === 'user' && rootMessage && msg.parent === rootMessage.id; + + const parentId = msg.parent || rootMessage?.id; + if (!parentId) return; + + const extrasToUse = + newExtras !== undefined + ? JSON.parse(JSON.stringify(newExtras)) + : msg.extra + ? JSON.parse(JSON.stringify(msg.extra)) + : undefined; + + const newMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: newContent, + thinking: msg.thinking || '', + toolCalls: msg.toolCalls || '', + children: [], + extra: extrasToUse, + model: msg.model + }, + parentId + ); + await conversationsStore.updateCurrentNode(newMessage.id); + conversationsStore.updateConversationTimestamp(); + + if (isFirstUserMessage && newContent.trim()) { + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + newContent.trim() + ); + } + await conversationsStore.refreshActiveMessages(); + + if (msg.role === 'user') { + await this.generateResponseForMessage(newMessage.id); + } + } catch (error) { + console.error('Failed to edit message with branching:', error); + } + } + + private async generateResponseForMessage(userMessageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + + if (!activeConv) return; + + this.store.showErrorDialog(null); + this.store.setChatLoading(activeConv.id, true); + this.store.clearChatStreaming(activeConv.id); + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const conversationPath = filterByLeafNodeId( + allMessages, + userMessageId, + false + ) as DatabaseMessage[]; + const assistantMessage = await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + type: 'text', + timestamp: Date.now(), + role: 'assistant', + content: '', + thinking: '', + toolCalls: '', + children: [], + model: null + }, + userMessageId + ); + conversationsStore.addMessageToActive(assistantMessage); + await this.streamChatCompletion(conversationPath, assistantMessage); + } catch (error) { + console.error('Failed to generate response:', error); + this.store.setChatLoading(activeConv.id, false); + } + } + + /** + * + * + * Processing State + * + * + */ + + /** + * Gets the total context size for the current model. + * Priority: active state > router model > server props > default. + */ + private getContextTotal(): number { + const activeConvId = this.store.getActiveConversationId(); + const activeState = activeConvId ? this.store.getProcessingState(activeConvId) : null; + + if (activeState && activeState.contextTotal > 0) { + return activeState.contextTotal; + } + + if (isRouterMode()) { + const modelContextSize = selectedModelContextSize(); + if (modelContextSize && modelContextSize > 0) { + return modelContextSize; + } + } + + const propsContextSize = contextSize(); + if (propsContextSize && propsContextSize > 0) { + return propsContextSize; + } + + return DEFAULT_CONTEXT; + } + + /** + * Updates processing state from streaming timing data. + * Called during streaming to update tokens/sec, context usage, etc. + * @param timingData - Timing information from the streaming response + * @param conversationId - Optional conversation ID (defaults to active) + */ + updateProcessingStateFromTimings( + timingData: { + prompt_n: number; + prompt_ms?: number; + predicted_n: number; + predicted_per_second: number; + cache_n: number; + prompt_progress?: ChatMessagePromptProgress; + }, + conversationId?: string + ): void { + const processingState = this.parseTimingData(timingData); + + if (processingState === null) { + console.warn('Failed to parse timing data - skipping update'); + return; + } + + const targetId = conversationId || this.store.getActiveConversationId(); + if (targetId) { + this.store.setProcessingState(targetId, processingState); + } + } + + private parseTimingData(timingData: Record): ApiProcessingState | null { + const promptTokens = (timingData.prompt_n as number) || 0; + const promptMs = (timingData.prompt_ms as number) || undefined; + const predictedTokens = (timingData.predicted_n as number) || 0; + const tokensPerSecond = (timingData.predicted_per_second as number) || 0; + const cacheTokens = (timingData.cache_n as number) || 0; + const promptProgress = timingData.prompt_progress as + | { + total: number; + cache: number; + processed: number; + time_ms: number; + } + | undefined; + + const contextTotal = this.getContextTotal(); + const currentConfig = config(); + const outputTokensMax = currentConfig.max_tokens || -1; + + const contextUsed = promptTokens + cacheTokens + predictedTokens; + const outputTokensUsed = predictedTokens; + + const progressCache = promptProgress?.cache || 0; + const progressActualDone = (promptProgress?.processed ?? 0) - progressCache; + const progressActualTotal = (promptProgress?.total ?? 0) - progressCache; + const progressPercent = promptProgress + ? Math.round((progressActualDone / progressActualTotal) * 100) + : undefined; + + return { + status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle', + tokensDecoded: predictedTokens, + tokensRemaining: outputTokensMax - predictedTokens, + contextUsed, + contextTotal, + outputTokensUsed, + outputTokensMax, + hasNextToken: predictedTokens > 0, + tokensPerSecond, + temperature: currentConfig.temperature ?? 0.8, + topP: currentConfig.top_p ?? 0.95, + speculative: false, + progressPercent, + promptProgress, + promptTokens, + promptMs, + cacheTokens + }; + } + + /** + * Restores processing state from stored message timings. + * Used when loading a conversation to show last known stats. + * @param messages - Conversation messages to search for timing data + * @param conversationId - Conversation ID to update state for + */ + restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === 'assistant' && message.timings) { + const restoredState = this.parseTimingData({ + prompt_n: message.timings.prompt_n || 0, + prompt_ms: message.timings.prompt_ms, + predicted_n: message.timings.predicted_n || 0, + predicted_per_second: + message.timings.predicted_n && message.timings.predicted_ms + ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000 + : 0, + cache_n: message.timings.cache_n || 0 + }); + + if (restoredState) { + this.store.setProcessingState(conversationId, restoredState); + return; + } + } + } + } + + /** + * Gets the model used in a conversation based on the latest assistant message. + */ + getConversationModel(messages: DatabaseMessage[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === 'assistant' && message.model) { + return message.model; + } + } + return null; + } + + /** + * + * + * Utilities + * + * + */ + + private isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException); + } + + private isChatLoading(convId: string): boolean { + const streamingState = this.store.getChatStreaming(convId); + return streamingState !== undefined; + } + + private getApiOptions(): Record { + const currentConfig = config(); + const hasValue = (value: unknown): boolean => + value !== undefined && value !== null && value !== ''; + + const apiOptions: Record = { stream: true, timings_per_token: true }; + + if (isRouterMode()) { + const modelName = selectedModelName(); + if (modelName) apiOptions.model = modelName; + } + + if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage; + if (currentConfig.disableReasoningParsing) apiOptions.disableReasoningParsing = true; + + if (hasValue(currentConfig.temperature)) + apiOptions.temperature = Number(currentConfig.temperature); + if (hasValue(currentConfig.max_tokens)) + apiOptions.max_tokens = Number(currentConfig.max_tokens); + if (hasValue(currentConfig.dynatemp_range)) + apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range); + if (hasValue(currentConfig.dynatemp_exponent)) + apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent); + if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k); + if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p); + if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p); + if (hasValue(currentConfig.xtc_probability)) + apiOptions.xtc_probability = Number(currentConfig.xtc_probability); + if (hasValue(currentConfig.xtc_threshold)) + apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold); + if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p); + if (hasValue(currentConfig.repeat_last_n)) + apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n); + if (hasValue(currentConfig.repeat_penalty)) + apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty); + if (hasValue(currentConfig.presence_penalty)) + apiOptions.presence_penalty = Number(currentConfig.presence_penalty); + if (hasValue(currentConfig.frequency_penalty)) + apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty); + if (hasValue(currentConfig.dry_multiplier)) + apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier); + if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base); + if (hasValue(currentConfig.dry_allowed_length)) + apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length); + if (hasValue(currentConfig.dry_penalty_last_n)) + apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n); + if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers; + if (currentConfig.backend_sampling) + apiOptions.backend_sampling = currentConfig.backend_sampling; + if (currentConfig.custom) apiOptions.custom = currentConfig.custom; + + return apiOptions; + } +} + +export const chatClient = new ChatClient(); diff --git a/tools/server/webui/src/lib/clients/conversations.client.ts b/tools/server/webui/src/lib/clients/conversations.client.ts new file mode 100644 index 0000000000..936a2399bb --- /dev/null +++ b/tools/server/webui/src/lib/clients/conversations.client.ts @@ -0,0 +1,713 @@ +/** + * ConversationsClient - Business Logic Facade for Conversation Operations + * + * Coordinates conversation lifecycle, persistence, and navigation. + * + * **Architecture & Relationships:** + * - **ConversationsClient** (this class): Business logic facade + * - Uses DatabaseService for IndexedDB operations + * - Updates conversationsStore with reactive state + * - Handles CRUD, import/export, branch navigation + * + * - **DatabaseService**: Stateless IndexedDB layer + * - **conversationsStore**: Reactive state only ($state) + * + * **Key Responsibilities:** + * - Conversation lifecycle (create, load, delete) + * - Message management and tree navigation + * - MCP server per-chat overrides + * - Import/Export functionality + * - Title management with confirmation + */ + +import { goto } from '$app/navigation'; +import { toast } from 'svelte-sonner'; +import { DatabaseService } from '$lib/services/database.service'; +import { config } from '$lib/stores/settings.svelte'; +import { filterByLeafNodeId, findLeafNode } from '$lib/utils'; +import type { McpServerOverride } from '$lib/types/database'; + +interface ConversationsStoreStateCallbacks { + getConversations: () => DatabaseConversation[]; + setConversations: (conversations: DatabaseConversation[]) => void; + getActiveConversation: () => DatabaseConversation | null; + setActiveConversation: (conversation: DatabaseConversation | null) => void; + getActiveMessages: () => DatabaseMessage[]; + setActiveMessages: (messages: DatabaseMessage[]) => void; + updateActiveMessages: (updater: (messages: DatabaseMessage[]) => DatabaseMessage[]) => void; + setInitialized: (initialized: boolean) => void; + getPendingMcpServerOverrides: () => McpServerOverride[]; + setPendingMcpServerOverrides: (overrides: McpServerOverride[]) => void; + getTitleUpdateConfirmationCallback: () => + | ((currentTitle: string, newTitle: string) => Promise) + | undefined; +} + +export class ConversationsClient { + private storeCallbacks: ConversationsStoreStateCallbacks | null = null; + + /** + * + * + * Store Integration + * + * + */ + + /** + * Sets callbacks for store state updates. + * Called by conversationsStore during initialization. + */ + setStoreCallbacks(callbacks: ConversationsStoreStateCallbacks): void { + this.storeCallbacks = callbacks; + } + + private get store(): ConversationsStoreStateCallbacks { + if (!this.storeCallbacks) { + throw new Error('ConversationsClient: Store callbacks not initialized'); + } + return this.storeCallbacks; + } + + /** + * + * + * Lifecycle + * + * + */ + + /** + * Initializes the conversations by loading from the database. + */ + async initialize(): Promise { + try { + await this.loadConversations(); + this.store.setInitialized(true); + } catch (error) { + console.error('Failed to initialize conversations:', error); + } + } + + /** + * Loads all conversations from the database + */ + async loadConversations(): Promise { + const conversations = await DatabaseService.getAllConversations(); + this.store.setConversations(conversations); + } + + /** + * Creates a new conversation and navigates to it + * @param name - Optional name for the conversation + * @returns The ID of the created conversation + */ + async createConversation(name?: string): Promise { + const conversationName = name || `Chat ${new Date().toLocaleString()}`; + const conversation = await DatabaseService.createConversation(conversationName); + + const pendingOverrides = this.store.getPendingMcpServerOverrides(); + if (pendingOverrides.length > 0) { + // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB) + const plainOverrides = pendingOverrides.map((o) => ({ + serverId: o.serverId, + enabled: o.enabled + })); + conversation.mcpServerOverrides = plainOverrides; + await DatabaseService.updateConversation(conversation.id, { + mcpServerOverrides: plainOverrides + }); + this.store.setPendingMcpServerOverrides([]); + } + + const conversations = this.store.getConversations(); + this.store.setConversations([conversation, ...conversations]); + this.store.setActiveConversation(conversation); + this.store.setActiveMessages([]); + + await goto(`#/chat/${conversation.id}`); + + return conversation.id; + } + + /** + * Loads a specific conversation and its messages + * @param convId - The conversation ID to load + * @returns True if conversation was loaded successfully + */ + async loadConversation(convId: string): Promise { + try { + const conversation = await DatabaseService.getConversation(convId); + + if (!conversation) { + return false; + } + + this.store.setPendingMcpServerOverrides([]); + this.store.setActiveConversation(conversation); + + if (conversation.currNode) { + const allMessages = await DatabaseService.getConversationMessages(convId); + const filteredMessages = filterByLeafNodeId( + allMessages, + conversation.currNode, + false + ) as DatabaseMessage[]; + this.store.setActiveMessages(filteredMessages); + } else { + const messages = await DatabaseService.getConversationMessages(convId); + this.store.setActiveMessages(messages); + } + + return true; + } catch (error) { + console.error('Failed to load conversation:', error); + return false; + } + } + + /** + * + * + * Conversation CRUD + * + * + */ + + /** + * Clears the active conversation and messages. + */ + clearActiveConversation(): void { + this.store.setActiveConversation(null); + this.store.setActiveMessages([]); + } + + /** + * Deletes a conversation and all its messages + * @param convId - The conversation ID to delete + */ + async deleteConversation(convId: string): Promise { + try { + await DatabaseService.deleteConversation(convId); + + const conversations = this.store.getConversations(); + this.store.setConversations(conversations.filter((c) => c.id !== convId)); + + const activeConv = this.store.getActiveConversation(); + if (activeConv?.id === convId) { + this.clearActiveConversation(); + await goto(`?new_chat=true#/`); + } + } catch (error) { + console.error('Failed to delete conversation:', error); + } + } + + /** + * Deletes all conversations and their messages + */ + async deleteAll(): Promise { + try { + const allConversations = await DatabaseService.getAllConversations(); + + for (const conv of allConversations) { + await DatabaseService.deleteConversation(conv.id); + } + + this.clearActiveConversation(); + this.store.setConversations([]); + + toast.success('All conversations deleted'); + + await goto(`?new_chat=true#/`); + } catch (error) { + console.error('Failed to delete all conversations:', error); + toast.error('Failed to delete conversations'); + } + } + + /** + * + * + * Message Management + * + * + */ + + /** + * Refreshes active messages based on currNode after branch navigation. + */ + async refreshActiveMessages(): Promise { + const activeConv = this.store.getActiveConversation(); + if (!activeConv) return; + + const allMessages = await DatabaseService.getConversationMessages(activeConv.id); + + if (allMessages.length === 0) { + this.store.setActiveMessages([]); + return; + } + + const leafNodeId = + activeConv.currNode || + allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id; + + const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[]; + + this.store.setActiveMessages(currentPath); + } + + /** + * Gets all messages for a specific conversation + * @param convId - The conversation ID + * @returns Array of messages + */ + async getConversationMessages(convId: string): Promise { + return await DatabaseService.getConversationMessages(convId); + } + + /** + * + * + * Title Management + * + * + */ + + /** + * Updates the name of a conversation. + * @param convId - The conversation ID to update + * @param name - The new name for the conversation + */ + async updateConversationName(convId: string, name: string): Promise { + try { + await DatabaseService.updateConversation(convId, { name }); + + const conversations = this.store.getConversations(); + const convIndex = conversations.findIndex((c) => c.id === convId); + + if (convIndex !== -1) { + conversations[convIndex].name = name; + this.store.setConversations([...conversations]); + } + + const activeConv = this.store.getActiveConversation(); + if (activeConv?.id === convId) { + this.store.setActiveConversation({ ...activeConv, name }); + } + } catch (error) { + console.error('Failed to update conversation name:', error); + } + } + + /** + * Updates conversation title with optional confirmation dialog based on settings + * @param convId - The conversation ID to update + * @param newTitle - The new title content + * @returns True if title was updated, false if cancelled + */ + async updateConversationTitleWithConfirmation( + convId: string, + newTitle: string + ): Promise { + try { + const currentConfig = config(); + const onConfirmationNeeded = this.store.getTitleUpdateConfirmationCallback(); + + if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) { + const conversation = await DatabaseService.getConversation(convId); + if (!conversation) return false; + + const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle); + if (!shouldUpdate) return false; + } + + await this.updateConversationName(convId, newTitle); + return true; + } catch (error) { + console.error('Failed to update conversation title with confirmation:', error); + return false; + } + } + + /** + * Updates conversation lastModified timestamp and moves it to top of list + */ + updateConversationTimestamp(): void { + const activeConv = this.store.getActiveConversation(); + if (!activeConv) return; + + const conversations = this.store.getConversations(); + const chatIndex = conversations.findIndex((c) => c.id === activeConv.id); + + if (chatIndex !== -1) { + conversations[chatIndex].lastModified = Date.now(); + const updatedConv = conversations.splice(chatIndex, 1)[0]; + this.store.setConversations([updatedConv, ...conversations]); + } + } + + /** + * Updates the current node of the active conversation + * @param nodeId - The new current node ID + */ + async updateCurrentNode(nodeId: string): Promise { + const activeConv = this.store.getActiveConversation(); + if (!activeConv) return; + + await DatabaseService.updateCurrentNode(activeConv.id, nodeId); + this.store.setActiveConversation({ ...activeConv, currNode: nodeId }); + } + + /** + * + * + * Branch Navigation + * + * + */ + + /** + * Navigates to a specific sibling branch by updating currNode and refreshing messages. + * @param siblingId - The sibling message ID to navigate to + */ + async navigateToSibling(siblingId: string): Promise { + const activeConv = this.store.getActiveConversation(); + if (!activeConv) return; + + const allMessages = await DatabaseService.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const activeMessages = this.store.getActiveMessages(); + const currentFirstUserMessage = activeMessages.find( + (m) => m.role === 'user' && m.parent === rootMessage?.id + ); + + const currentLeafNodeId = findLeafNode(allMessages, siblingId); + + await DatabaseService.updateCurrentNode(activeConv.id, currentLeafNodeId); + this.store.setActiveConversation({ ...activeConv, currNode: currentLeafNodeId }); + await this.refreshActiveMessages(); + + const updatedActiveMessages = this.store.getActiveMessages(); + if (rootMessage && updatedActiveMessages.length > 0) { + const newFirstUserMessage = updatedActiveMessages.find( + (m) => m.role === 'user' && m.parent === rootMessage.id + ); + + if ( + newFirstUserMessage && + newFirstUserMessage.content.trim() && + (!currentFirstUserMessage || + newFirstUserMessage.id !== currentFirstUserMessage.id || + newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim()) + ) { + await this.updateConversationTitleWithConfirmation( + activeConv.id, + newFirstUserMessage.content.trim() + ); + } + } + } + + /** + * + * + * MCP Server Overrides + * + * + */ + + /** + * Gets MCP server override for a specific server in the active conversation. + * Falls back to pending overrides if no active conversation exists. + * @param serverId - The server ID to check + * @returns The override if set, undefined if using global setting + */ + getMcpServerOverride(serverId: string): McpServerOverride | undefined { + const activeConv = this.store.getActiveConversation(); + if (activeConv) { + return activeConv.mcpServerOverrides?.find((o: McpServerOverride) => o.serverId === serverId); + } + return this.store.getPendingMcpServerOverrides().find((o) => o.serverId === serverId); + } + + /** + * Checks if an MCP server is enabled for the active conversation. + * Per-chat override takes precedence over global setting. + * @param serverId - The server ID to check + * @param globalEnabled - The global enabled state from settings + * @returns True if server is enabled for this conversation + */ + isMcpServerEnabledForChat(serverId: string, globalEnabled: boolean): boolean { + const override = this.getMcpServerOverride(serverId); + return override !== undefined ? override.enabled : globalEnabled; + } + + /** + * Sets or removes MCP server override for the active conversation. + * If no conversation exists, stores as pending override. + * @param serverId - The server ID to override + * @param enabled - The enabled state, or undefined to remove override + */ + async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise { + const activeConv = this.store.getActiveConversation(); + + if (!activeConv) { + this.setPendingMcpServerOverride(serverId, enabled); + return; + } + + // Clone to plain objects to avoid Proxy serialization issues with IndexedDB + const currentOverrides = (activeConv.mcpServerOverrides || []).map((o: McpServerOverride) => ({ + serverId: o.serverId, + enabled: o.enabled + })); + let newOverrides: McpServerOverride[]; + + if (enabled === undefined) { + newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId); + } else { + const existingIndex = currentOverrides.findIndex( + (o: McpServerOverride) => o.serverId === serverId + ); + if (existingIndex >= 0) { + newOverrides = [...currentOverrides]; + newOverrides[existingIndex] = { serverId, enabled }; + } else { + newOverrides = [...currentOverrides, { serverId, enabled }]; + } + } + + await DatabaseService.updateConversation(activeConv.id, { + mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined + }); + + const updatedConv = { + ...activeConv, + mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined + }; + this.store.setActiveConversation(updatedConv); + + const conversations = this.store.getConversations(); + const convIndex = conversations.findIndex((c) => c.id === activeConv.id); + if (convIndex !== -1) { + conversations[convIndex].mcpServerOverrides = + newOverrides.length > 0 ? newOverrides : undefined; + this.store.setConversations([...conversations]); + } + } + + /** + * Toggles MCP server enabled state for the active conversation. + * @param serverId - The server ID to toggle + * @param globalEnabled - The global enabled state from settings + */ + async toggleMcpServerForChat(serverId: string, globalEnabled: boolean): Promise { + const currentEnabled = this.isMcpServerEnabledForChat(serverId, globalEnabled); + await this.setMcpServerOverride(serverId, !currentEnabled); + } + + /** + * Resets MCP server to use global setting (removes per-chat override). + * @param serverId - The server ID to reset + */ + async resetMcpServerToGlobal(serverId: string): Promise { + await this.setMcpServerOverride(serverId, undefined); + } + + /** + * Sets or removes a pending MCP server override (for new conversations). + */ + private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void { + const pendingOverrides = this.store.getPendingMcpServerOverrides(); + + if (enabled === undefined) { + this.store.setPendingMcpServerOverrides( + pendingOverrides.filter((o) => o.serverId !== serverId) + ); + } else { + const existingIndex = pendingOverrides.findIndex((o) => o.serverId === serverId); + if (existingIndex >= 0) { + const newOverrides = [...pendingOverrides]; + newOverrides[existingIndex] = { serverId, enabled }; + this.store.setPendingMcpServerOverrides(newOverrides); + } else { + this.store.setPendingMcpServerOverrides([...pendingOverrides, { serverId, enabled }]); + } + } + } + + /** + * Clears all pending MCP server overrides. + */ + clearPendingMcpServerOverrides(): void { + this.store.setPendingMcpServerOverrides([]); + } + + /** + * + * + * Import & Export + * + * + */ + + /** + * Downloads a conversation as JSON file. + * @param convId - The conversation ID to download + */ + async downloadConversation(convId: string): Promise { + let conversation: DatabaseConversation | null; + let messages: DatabaseMessage[]; + + const activeConv = this.store.getActiveConversation(); + if (activeConv?.id === convId) { + conversation = activeConv; + messages = this.store.getActiveMessages(); + } else { + conversation = await DatabaseService.getConversation(convId); + if (!conversation) return; + messages = await DatabaseService.getConversationMessages(convId); + } + + this.triggerDownload({ conv: conversation, messages }); + } + + /** + * Exports all conversations with their messages as a JSON file + * @returns The list of exported conversations + */ + async exportAllConversations(): Promise { + const allConversations = await DatabaseService.getAllConversations(); + + if (allConversations.length === 0) { + throw new Error('No conversations to export'); + } + + const allData = await Promise.all( + allConversations.map(async (conv) => { + const messages = await DatabaseService.getConversationMessages(conv.id); + return { conv, messages }; + }) + ); + + const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success(`All conversations (${allConversations.length}) prepared for download`); + + return allConversations; + } + + /** + * Imports conversations from a JSON file + * Opens file picker and processes the selected file + * @returns The list of imported conversations + */ + async importConversations(): Promise { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement)?.files?.[0]; + + if (!file) { + reject(new Error('No file selected')); + return; + } + + try { + const text = await file.text(); + const parsedData = JSON.parse(text); + let importedData: ExportedConversations; + + if (Array.isArray(parsedData)) { + importedData = parsedData; + } else if ( + parsedData && + typeof parsedData === 'object' && + 'conv' in parsedData && + 'messages' in parsedData + ) { + importedData = [parsedData]; + } else { + throw new Error('Invalid file format'); + } + + const result = await DatabaseService.importConversations(importedData); + toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`); + + await this.loadConversations(); + + const importedConversations = ( + Array.isArray(importedData) ? importedData : [importedData] + ).map((item) => item.conv); + + resolve(importedConversations); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('Failed to import conversations:', err); + toast.error('Import failed', { description: message }); + reject(new Error(`Import failed: ${message}`)); + } + }; + + input.click(); + }); + } + + /** + * Imports conversations from provided data (without file picker) + * @param data - Array of conversation data with messages + * @returns Import result with counts + */ + async importConversationsData( + data: ExportedConversations + ): Promise<{ imported: number; skipped: number }> { + const result = await DatabaseService.importConversations(data); + await this.loadConversations(); + return result; + } + + /** + * Triggers file download in browser + */ + private triggerDownload(data: ExportedConversations, filename?: string): void { + const conversation = + 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined; + + if (!conversation) { + console.error('Invalid data: missing conversation'); + return; + } + + const conversationName = conversation.name?.trim() || ''; + const truncatedSuffix = conversationName + .toLowerCase() + .replace(/[^a-z0-9]/gi, '_') + .replace(/_+/g, '_') + .substring(0, 20); + const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`; + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = downloadFilename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } +} + +export const conversationsClient = new ConversationsClient(); diff --git a/tools/server/webui/src/lib/clients/index.ts b/tools/server/webui/src/lib/clients/index.ts new file mode 100644 index 0000000000..f35971d745 --- /dev/null +++ b/tools/server/webui/src/lib/clients/index.ts @@ -0,0 +1,37 @@ +/** + * Clients Module - Business Logic Facades + * + * This module exports all client classes which coordinate business logic: + * - MCPClient: MCP connection management and tool execution + * - ChatClient: Message operations, streaming, branching + * - AgenticClient: Multi-turn tool loop orchestration + * - ConversationsClient: Conversation CRUD and message management + * + * **Architecture:** + * - Clients coordinate between Services (stateless API) and Stores (reactive state) + * - Clients contain business logic, orchestration, and error handling + * - Stores only hold reactive state and delegate to Clients + * + * @see services/ for stateless API operations + * @see stores/ for reactive state + */ + +// MCP Client +export { MCPClient, mcpClient } from './mcp.client'; +export type { HealthCheckState, HealthCheckParams } from './mcp.client'; + +// Chat Client +export { ChatClient, chatClient } from './chat.client'; +export type { ChatStreamCallbacks, ApiProcessingState, ErrorDialogState } from './chat.client'; + +// Agentic Client +export { AgenticClient, agenticClient } from './agentic.client'; +export type { + AgenticFlowCallbacks, + AgenticFlowOptions, + AgenticFlowParams, + AgenticFlowResult +} from './agentic.client'; + +// Conversations Client +export { ConversationsClient, conversationsClient } from './conversations.client'; diff --git a/tools/server/webui/src/lib/clients/mcp.client.ts b/tools/server/webui/src/lib/clients/mcp.client.ts new file mode 100644 index 0000000000..59b118fad3 --- /dev/null +++ b/tools/server/webui/src/lib/clients/mcp.client.ts @@ -0,0 +1,615 @@ +/** + * MCPClient - Business Logic Facade for MCP Operations + * + * Implements the "Host" role in MCP architecture, coordinating multiple server + * connections and providing a unified interface for tool operations. + * + * **Architecture & Relationships:** + * - **MCPClient** (this class): Business logic facade + * - Uses MCPService for low-level protocol operations + * - Updates mcpStore with reactive state + * - Coordinates multiple server connections + * - Aggregates tools from all connected servers + * - Routes tool calls to the appropriate server + * + * - **MCPService**: Stateless protocol layer (transport, connect, callTool) + * - **mcpStore**: Reactive state only ($state, getters, setters) + * + * **Key Responsibilities:** + * - Lifecycle management (initialize, shutdown) + * - Multi-server coordination + * - Tool name conflict detection and resolution + * - OpenAI-compatible tool definition generation + * - Automatic tool-to-server routing + * - Health checks + * - Usage statistics tracking + */ + +import { browser } from '$app/environment'; +import { MCPService, type MCPConnection } from '$lib/services/mcp.service'; +import type { + MCPToolCall, + OpenAIToolDefinition, + ServerStatus, + ToolExecutionResult, + MCPClientConfig +} from '$lib/types/mcp'; +import type { McpServerOverride } from '$lib/types/database'; +import { MCPError } from '$lib/errors'; +import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/utils/mcp'; +import { config, settingsStore } from '$lib/stores/settings.svelte'; +import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; +import { detectMcpTransportFromUrl } from '$lib/utils/mcp'; + +export type HealthCheckState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'success'; tools: { name: string; description?: string }[] }; + +export interface HealthCheckParams { + id: string; + url: string; + requestTimeoutSeconds: number; + headers?: string; +} + +export class MCPClient { + private connections = new Map(); + private toolsIndex = new Map(); + private configSignature: string | null = null; + private initPromise: Promise | null = null; + + private onStateChange?: (state: { + isInitializing?: boolean; + error?: string | null; + toolCount?: number; + connectedServers?: string[]; + }) => void; + + private onHealthCheckChange?: (serverId: string, state: HealthCheckState) => void; + + /** + * + * + * Store Integration + * + * + */ + + /** + * Sets callback for state changes. + * Called by mcpStore to sync reactive state. + */ + setStateChangeCallback( + callback: (state: { + isInitializing?: boolean; + error?: string | null; + toolCount?: number; + connectedServers?: string[]; + }) => void + ): void { + this.onStateChange = callback; + } + + /** + * Set callback for health check state changes + */ + setHealthCheckCallback(callback: (serverId: string, state: HealthCheckState) => void): void { + this.onHealthCheckChange = callback; + } + + private notifyStateChange(state: Parameters>[0]): void { + this.onStateChange?.(state); + } + + private notifyHealthCheck(serverId: string, state: HealthCheckState): void { + this.onHealthCheckChange?.(serverId, state); + } + + /** + * + * + * Lifecycle + * + * + */ + + /** + * Ensures MCP is initialized with current config. + * Handles config changes by reinitializing as needed. + * @param perChatOverrides - Optional per-chat MCP server overrides + */ + async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise { + if (!browser) return false; + + const mcpConfig = buildMcpClientConfig(config(), perChatOverrides); + const signature = mcpConfig ? JSON.stringify(mcpConfig) : null; + + if (!signature) { + await this.shutdown(); + return false; + } + + if (this.isInitialized && this.configSignature === signature) { + return true; + } + + if (this.initPromise && this.configSignature === signature) { + return this.initPromise; + } + + if (this.connections.size > 0 || this.initPromise) { + await this.shutdown(); + } + + return this.initialize(signature, mcpConfig!); + } + + /** + * Initialize connections to all configured MCP servers. + */ + private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise { + console.log('[MCPClient] Starting initialization...'); + + this.notifyStateChange({ isInitializing: true, error: null }); + this.configSignature = signature; + + const serverEntries = Object.entries(mcpConfig.servers); + if (serverEntries.length === 0) { + console.log('[MCPClient] No servers configured'); + this.notifyStateChange({ isInitializing: false, toolCount: 0, connectedServers: [] }); + return false; + } + + this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries); + return this.initPromise; + } + + private async doInitialize( + signature: string, + mcpConfig: MCPClientConfig, + serverEntries: [string, MCPClientConfig['servers'][string]][] + ): Promise { + const clientInfo = mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; + const capabilities = mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities; + + const results = await Promise.allSettled( + serverEntries.map(async ([name, serverConfig]) => { + const connection = await MCPService.connect(name, serverConfig, clientInfo, capabilities); + return { name, connection }; + }) + ); + + if (this.configSignature !== signature) { + console.log('[MCPClient] Config changed during init, aborting'); + for (const result of results) { + if (result.status === 'fulfilled') { + await MCPService.disconnect(result.value.connection).catch(console.warn); + } + } + return false; + } + + for (const result of results) { + if (result.status === 'fulfilled') { + const { name, connection } = result.value; + this.connections.set(name, connection); + + for (const tool of connection.tools) { + if (this.toolsIndex.has(tool.name)) { + console.warn( + `[MCPClient] Tool name conflict: "${tool.name}" exists in ` + + `"${this.toolsIndex.get(tool.name)}" and "${name}". ` + + `Using tool from "${name}".` + ); + } + this.toolsIndex.set(tool.name, name); + } + } else { + console.error(`[MCPClient] Failed to connect:`, result.reason); + } + } + + const successCount = this.connections.size; + const totalCount = serverEntries.length; + + if (successCount === 0 && totalCount > 0) { + const error = 'All MCP server connections failed'; + this.notifyStateChange({ + isInitializing: false, + error, + toolCount: 0, + connectedServers: [] + }); + this.initPromise = null; + return false; + } + + this.notifyStateChange({ + isInitializing: false, + error: null, + toolCount: this.toolsIndex.size, + connectedServers: Array.from(this.connections.keys()) + }); + + console.log( + `[MCPClient] Initialization complete: ${successCount}/${totalCount} servers connected, ` + + `${this.toolsIndex.size} tools available` + ); + + this.initPromise = null; + return true; + } + + /** + * Shutdown all MCP connections and clear state. + */ + async shutdown(): Promise { + if (this.initPromise) { + await this.initPromise.catch(() => {}); + this.initPromise = null; + } + + if (this.connections.size === 0) { + return; + } + + console.log(`[MCPClient] Shutting down ${this.connections.size} connections...`); + + await Promise.all( + Array.from(this.connections.values()).map((conn) => + MCPService.disconnect(conn).catch((error) => { + console.warn(`[MCPClient] Error disconnecting ${conn.serverName}:`, error); + }) + ) + ); + + this.connections.clear(); + this.toolsIndex.clear(); + this.configSignature = null; + + this.notifyStateChange({ + isInitializing: false, + error: null, + toolCount: 0, + connectedServers: [] + }); + + console.log('[MCPClient] Shutdown complete'); + } + + /** + * + * + * Tool Definitions + * + * + */ + + /** + * Returns tools in OpenAI function calling format. + * Ready to be sent to /v1/chat/completions API. + */ + getToolDefinitionsForLLM(): OpenAIToolDefinition[] { + const tools: OpenAIToolDefinition[] = []; + + for (const connection of this.connections.values()) { + for (const tool of connection.tools) { + const rawSchema = (tool.inputSchema as Record) ?? { + type: 'object', + properties: {}, + required: [] + }; + + const normalizedSchema = this.normalizeSchemaProperties(rawSchema); + + tools.push({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: normalizedSchema + } + }); + } + } + + return tools; + } + + /** + * Normalize JSON Schema properties to ensure all have explicit types. + * Infers type from default value if missing - fixes compatibility with + * llama.cpp which requires explicit types in tool schemas. + */ + private normalizeSchemaProperties(schema: Record): Record { + if (!schema || typeof schema !== 'object') return schema; + + const normalized = { ...schema }; + + if (normalized.properties && typeof normalized.properties === 'object') { + const props = normalized.properties as Record>; + const normalizedProps: Record> = {}; + + for (const [key, prop] of Object.entries(props)) { + if (!prop || typeof prop !== 'object') { + normalizedProps[key] = prop; + continue; + } + + const normalizedProp = { ...prop }; + + // Infer type from default if missing + if (!normalizedProp.type && normalizedProp.default !== undefined) { + const defaultVal = normalizedProp.default; + if (typeof defaultVal === 'string') { + normalizedProp.type = 'string'; + } else if (typeof defaultVal === 'number') { + normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number'; + } else if (typeof defaultVal === 'boolean') { + normalizedProp.type = 'boolean'; + } else if (Array.isArray(defaultVal)) { + normalizedProp.type = 'array'; + } else if (typeof defaultVal === 'object' && defaultVal !== null) { + normalizedProp.type = 'object'; + } + } + + if (normalizedProp.properties) { + Object.assign( + normalizedProp, + this.normalizeSchemaProperties(normalizedProp as Record) + ); + } + + if (normalizedProp.items && typeof normalizedProp.items === 'object') { + normalizedProp.items = this.normalizeSchemaProperties( + normalizedProp.items as Record + ); + } + + normalizedProps[key] = normalizedProp; + } + + normalized.properties = normalizedProps; + } + + return normalized; + } + + /** + * + * + * Tool Queries + * + * + */ + + /** + * Returns names of all available tools. + */ + getToolNames(): string[] { + return Array.from(this.toolsIndex.keys()); + } + + /** + * Check if a tool exists. + */ + hasTool(toolName: string): boolean { + return this.toolsIndex.has(toolName); + } + + /** + * Get which server provides a specific tool. + */ + getToolServer(toolName: string): string | undefined { + return this.toolsIndex.get(toolName); + } + + /** + * + * + * Tool Execution + * + * + */ + + /** + * Executes a tool call, automatically routing to the appropriate server. + * Accepts the OpenAI-style tool call format. + * @param toolCall - Tool call with function name and arguments + * @param signal - Optional abort signal + * @returns Tool execution result + */ + async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise { + const toolName = toolCall.function.name; + + const serverName = this.toolsIndex.get(toolName); + if (!serverName) { + throw new MCPError(`Unknown tool: ${toolName}`, -32601); + } + + const connection = this.connections.get(serverName); + if (!connection) { + throw new MCPError(`Server "${serverName}" is not connected`, -32000); + } + + const updatedStats = incrementMcpServerUsage(config(), serverName); + settingsStore.updateConfig('mcpServerUsageStats', updatedStats); + + const args = this.parseToolArguments(toolCall.function.arguments); + return MCPService.callTool(connection, { name: toolName, arguments: args }, signal); + } + + /** + * Executes a tool by name with arguments object. + * Simpler interface for direct tool calls. + * @param toolName - Name of the tool to execute + * @param args - Tool arguments as key-value pairs + * @param signal - Optional abort signal + */ + async executeToolByName( + toolName: string, + args: Record, + signal?: AbortSignal + ): Promise { + const serverName = this.toolsIndex.get(toolName); + if (!serverName) { + throw new MCPError(`Unknown tool: ${toolName}`, -32601); + } + + const connection = this.connections.get(serverName); + if (!connection) { + throw new MCPError(`Server "${serverName}" is not connected`, -32000); + } + + return MCPService.callTool(connection, { name: toolName, arguments: args }, signal); + } + + private parseToolArguments(args: string | Record): Record { + if (typeof args === 'string') { + const trimmed = args.trim(); + if (trimmed === '') { + return {}; + } + + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new MCPError( + `Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`, + -32602 + ); + } + return parsed as Record; + } catch (error) { + if (error instanceof MCPError) { + throw error; + } + throw new MCPError( + `Failed to parse tool arguments as JSON: ${(error as Error).message}`, + -32700 + ); + } + } + + if (typeof args === 'object' && args !== null && !Array.isArray(args)) { + return args; + } + + throw new MCPError(`Invalid tool arguments type: ${typeof args}`, -32602); + } + + /** + * + * + * Health Checks + * + * + */ + + private parseHeaders(headersJson?: string): Record | undefined { + if (!headersJson?.trim()) return undefined; + try { + const parsed = JSON.parse(headersJson); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + console.warn('[MCPClient] Failed to parse custom headers JSON:', headersJson); + } + return undefined; + } + + /** + * Run health check for a specific server configuration. + * Creates a temporary connection to test connectivity and list tools. + */ + async runHealthCheck(server: HealthCheckParams): Promise { + const trimmedUrl = server.url.trim(); + + if (!trimmedUrl) { + this.notifyHealthCheck(server.id, { + status: 'error', + message: 'Please enter a server URL first.' + }); + return; + } + + this.notifyHealthCheck(server.id, { status: 'loading' }); + + const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000); + const headers = this.parseHeaders(server.headers); + + try { + const connection = await MCPService.connect( + server.id, + { + url: trimmedUrl, + transport: detectMcpTransportFromUrl(trimmedUrl), + handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs, + requestTimeoutMs: timeoutMs, + headers + }, + DEFAULT_MCP_CONFIG.clientInfo, + DEFAULT_MCP_CONFIG.capabilities + ); + + const tools = connection.tools.map((tool) => ({ + name: tool.name, + description: tool.description + })); + + this.notifyHealthCheck(server.id, { status: 'success', tools }); + await MCPService.disconnect(connection); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + this.notifyHealthCheck(server.id, { status: 'error', message }); + } + } + + /** + * + * + * Status Getters + * + * + */ + + get isInitialized(): boolean { + return this.connections.size > 0; + } + + get connectedServerCount(): number { + return this.connections.size; + } + + get connectedServerNames(): string[] { + return Array.from(this.connections.keys()); + } + + get toolCount(): number { + return this.toolsIndex.size; + } + + /** + * Get status of all connected servers. + */ + getServersStatus(): ServerStatus[] { + const statuses: ServerStatus[] = []; + + for (const [name, connection] of this.connections) { + statuses.push({ + name, + isConnected: true, + toolCount: connection.tools.length, + error: undefined + }); + } + + return statuses; + } +} + +export const mcpClient = new MCPClient(); diff --git a/tools/server/webui/src/lib/clients/openai-sse.ts b/tools/server/webui/src/lib/clients/openai-sse.ts deleted file mode 100644 index 2de9533d1d..0000000000 --- a/tools/server/webui/src/lib/clients/openai-sse.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { - ApiChatCompletionToolCall, - ApiChatCompletionToolCallDelta, - ApiChatCompletionStreamChunk -} from '$lib/types/api'; -import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat'; -import { mergeToolCallDeltas, extractModelName } from '$lib/utils/chat-stream'; -import type { AgenticChatCompletionRequest } from '$lib/types/agentic'; - -export type OpenAISseCallbacks = { - onChunk?: (chunk: string) => void; - onReasoningChunk?: (chunk: string) => void; - onToolCallChunk?: (serializedToolCalls: string) => void; - onModel?: (model: string) => void; - onFirstValidChunk?: () => void; - onProcessingUpdate?: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => void; -}; - -export type OpenAISseTurnResult = { - content: string; - reasoningContent?: string; - toolCalls: ApiChatCompletionToolCall[]; - finishReason?: string | null; - timings?: ChatMessageTimings; -}; - -export type OpenAISseClientOptions = { - url: string; - buildHeaders?: () => Record; -}; - -export class OpenAISseClient { - constructor(private readonly options: OpenAISseClientOptions) {} - - async stream( - request: AgenticChatCompletionRequest, - callbacks: OpenAISseCallbacks = {}, - abortSignal?: AbortSignal - ): Promise { - const response = await fetch(this.options.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(this.options.buildHeaders?.() ?? {}) - }, - body: JSON.stringify(request), - signal: abortSignal - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || `LLM request failed (${response.status})`); - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('LLM response stream is not available'); - } - - return this.consumeStream(reader, callbacks, abortSignal); - } - - private async consumeStream( - reader: ReadableStreamDefaultReader, - callbacks: OpenAISseCallbacks, - abortSignal?: AbortSignal - ): Promise { - const decoder = new TextDecoder(); - let buffer = ''; - let aggregatedContent = ''; - let aggregatedReasoning = ''; - let aggregatedToolCalls: ApiChatCompletionToolCall[] = []; - let hasOpenToolCallBatch = false; - let toolCallIndexOffset = 0; - let finishReason: string | null | undefined; - let lastTimings: ChatMessageTimings | undefined; - let modelEmitted = false; - let firstValidChunkEmitted = false; - - const finalizeToolCallBatch = () => { - if (!hasOpenToolCallBatch) return; - toolCallIndexOffset = aggregatedToolCalls.length; - hasOpenToolCallBatch = false; - }; - - const processToolCalls = (toolCalls?: ApiChatCompletionToolCallDelta[]) => { - if (!toolCalls || toolCalls.length === 0) { - return; - } - aggregatedToolCalls = mergeToolCallDeltas( - aggregatedToolCalls, - toolCalls, - toolCallIndexOffset - ); - if (aggregatedToolCalls.length === 0) { - return; - } - hasOpenToolCallBatch = true; - }; - - try { - while (true) { - if (abortSignal?.aborted) { - throw new DOMException('Aborted', 'AbortError'); - } - - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) { - continue; - } - - const payload = line.slice(6); - if (payload === '[DONE]' || payload.trim().length === 0) { - continue; - } - - let chunk: ApiChatCompletionStreamChunk; - try { - chunk = JSON.parse(payload) as ApiChatCompletionStreamChunk; - } catch (error) { - console.error('[Agentic][SSE] Failed to parse chunk:', error); - continue; - } - - if (!firstValidChunkEmitted && chunk.object === 'chat.completion.chunk') { - firstValidChunkEmitted = true; - callbacks.onFirstValidChunk?.(); - } - - const choice = chunk.choices?.[0]; - const delta = choice?.delta; - finishReason = choice?.finish_reason ?? finishReason; - - if (!modelEmitted) { - const chunkModel = extractModelName(chunk); - if (chunkModel) { - modelEmitted = true; - callbacks.onModel?.(chunkModel); - } - } - - if (chunk.timings || chunk.prompt_progress) { - callbacks.onProcessingUpdate?.(chunk.timings, chunk.prompt_progress); - if (chunk.timings) { - lastTimings = chunk.timings; - } - } - - if (delta?.content) { - finalizeToolCallBatch(); - aggregatedContent += delta.content; - callbacks.onChunk?.(delta.content); - } - - if (delta?.reasoning_content) { - finalizeToolCallBatch(); - aggregatedReasoning += delta.reasoning_content; - callbacks.onReasoningChunk?.(delta.reasoning_content); - } - - processToolCalls(delta?.tool_calls); - } - } - - finalizeToolCallBatch(); - } catch (error) { - if ((error as Error).name === 'AbortError') { - throw error; - } - throw error instanceof Error ? error : new Error('LLM stream error'); - } finally { - reader.releaseLock(); - } - - return { - content: aggregatedContent, - reasoningContent: aggregatedReasoning || undefined, - toolCalls: aggregatedToolCalls, - finishReason, - timings: lastTimings - }; - } -} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte index f1f8b2362d..293fef854b 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte @@ -13,13 +13,18 @@ SyntaxHighlightedCode } from '$lib/components/app'; import { config } from '$lib/stores/settings.svelte'; + import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte'; import { Wrench, Loader2 } from '@lucide/svelte'; import { AgenticSectionType } from '$lib/enums'; import { AGENTIC_TAGS, AGENTIC_REGEX } from '$lib/constants/agentic'; import { formatJsonPretty } from '$lib/utils/formatters'; + import { decodeBase64 } from '$lib/utils'; + import type { ChatMessageToolCallTiming } from '$lib/types/chat'; interface Props { content: string; + isStreaming?: boolean; + toolCallTimings?: ChatMessageToolCallTiming[]; } interface AgenticSection { @@ -30,10 +35,18 @@ toolResult?: string; } - let { content }: Props = $props(); + let { content, isStreaming = false, toolCallTimings = [] }: Props = $props(); const sections = $derived(parseAgenticContent(content)); + // Get timing for a specific tool call by index (completed tool calls only) + function getToolCallTiming(toolCallIndex: number): ChatMessageToolCallTiming | undefined { + return toolCallTimings[toolCallIndex]; + } + + // Get streaming tool call from reactive store (not from content markers) + const streamingToolCall = $derived(isStreaming ? agenticStreamingToolCall() : null); + let expandedStates: Record = $state({}); const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean); @@ -74,12 +87,7 @@ const toolName = match[1]; const toolArgsBase64 = match[2]; - let toolArgs = ''; - try { - toolArgs = decodeURIComponent(escape(atob(toolArgsBase64))); - } catch { - toolArgs = toolArgsBase64; - } + const toolArgs = decodeBase64(toolArgsBase64); const toolResult = match[3].replace(/^\n+|\n+$/g, ''); sections.push({ @@ -112,12 +120,7 @@ const toolName = pendingMatch[1]; const toolArgsBase64 = pendingMatch[2]; - let toolArgs = ''; - try { - toolArgs = decodeURIComponent(escape(atob(toolArgsBase64))); - } catch { - toolArgs = toolArgsBase64; - } + const toolArgs = decodeBase64(toolArgsBase64); // Capture streaming result content (everything after args marker) const streamingResult = (pendingMatch[3] || '').replace(/^\n+|\n+$/g, ''); @@ -138,23 +141,7 @@ } const partialArgsBase64 = partialWithNameMatch[2] || ''; - let partialArgs = ''; - if (partialArgsBase64) { - try { - // Try to decode - may fail if incomplete base64 - partialArgs = decodeURIComponent(escape(atob(partialArgsBase64))); - } catch { - // If decoding fails, try padding the base64 - try { - const padded = - partialArgsBase64 + '=='.slice(0, (4 - (partialArgsBase64.length % 4)) % 4); - partialArgs = decodeURIComponent(escape(atob(padded))); - } catch { - // Show raw base64 if all decoding fails - partialArgs = ''; - } - } - } + const partialArgs = decodeBase64(partialArgsBase64); sections.push({ type: AgenticSectionType.TOOL_CALL_STREAMING, @@ -214,46 +201,25 @@
- {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING} - toggleExpanded(index, true)} - > -
-
- Arguments: - -
- {#if section.toolArgs} - - {:else} -
- Receiving arguments... -
- {/if} -
-
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING} {@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING} {@const toolIcon = isPending ? Loader2 : Wrench} {@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} + {@const toolCallIndex = + sections.slice(0, index + 1).filter((s) => s.type === AgenticSectionType.TOOL_CALL).length - + 1} + {@const timing = !isPending ? getToolCallTiming(toolCallIndex) : undefined} toggleExpanded(index, isPending)} > {#if section.toolArgs && section.toolArgs !== '{}'} @@ -289,6 +255,37 @@ {/if} {/each} + + {#if streamingToolCall} + {}} + > +
+
+ Arguments: + +
+ {#if streamingToolCall.arguments} + + {:else} +
+ Receiving arguments... +
+ {/if} +
+
+ {/if}