diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index 43f3f7bfba..7df0f42a80 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/src/lib/clients/agentic.client.ts b/tools/server/webui/src/lib/clients/agentic.client.ts deleted file mode 100644 index 831cbe1e25..0000000000 --- a/tools/server/webui/src/lib/clients/agentic.client.ts +++ /dev/null @@ -1,707 +0,0 @@ -/** - * 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 { BaseClient } from './base-client'; -import { mcpClient } from '$lib/clients'; -import { ChatService } from '$lib/services'; -import { config } from '$lib/stores/settings.svelte'; -import { agenticStore } from '$lib/stores/agentic.svelte'; -import type { - AgenticMessage, - AgenticToolCallList, - AgenticConfig, - AgenticFlowCallbacks, - AgenticFlowOptions, - AgenticFlowParams, - AgenticFlowResult -} from '$lib/types/agentic'; -import type { - ApiChatCompletionToolCall, - ApiChatMessageData, - ApiChatMessageContentPart -} from '$lib/types/api'; -import type { - ChatMessagePromptProgress, - ChatMessageTimings, - ChatMessageAgenticTimings, - ChatMessageToolCallTiming, - ChatMessageAgenticTurnStats -} from '$lib/types/chat'; -import type { MCPToolCall } from '$lib/types'; -import type { - DatabaseMessage, - DatabaseMessageExtra, - DatabaseMessageExtraImageFile -} from '$lib/types/database'; -import { AttachmentType, MessageRole } from '$lib/enums'; -import { isAbortError } from '$lib/utils'; - -/** - * Converts API messages to agentic format. - */ -function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] { - return messages.map((message) => { - if ( - message.role === MessageRole.ASSISTANT && - message.tool_calls && - message.tool_calls.length > 0 - ) { - return { - role: MessageRole.ASSISTANT, - content: message.content, - tool_calls: message.tool_calls.map((call, index) => ({ - id: call.id ?? `call_${index}`, - type: (call.type as 'function') ?? 'function', - function: { - name: call.function?.name ?? '', - arguments: call.function?.arguments ?? '' - } - })) - } satisfies AgenticMessage; - } - - if (message.role === MessageRole.TOOL && message.tool_call_id) { - return { - role: MessageRole.TOOL, - tool_call_id: message.tool_call_id, - content: typeof message.content === 'string' ? message.content : '' - } satisfies AgenticMessage; - } - - return { - role: message.role as MessageRole.SYSTEM | MessageRole.USER, - content: message.content - } satisfies AgenticMessage; - }); -} - -interface AgenticStoreStateCallbacks { - setRunning: (conversationId: string, running: boolean) => void; - setCurrentTurn: (conversationId: string, turn: number) => void; - setTotalToolCalls: (conversationId: string, count: number) => void; - setLastError: (conversationId: string, error: Error | null) => void; - setStreamingToolCall: ( - conversationId: string, - tc: { name: string; arguments: string } | null - ) => void; - clearStreamingToolCall: (conversationId: string) => void; -} - -export class AgenticClient extends BaseClient { - /** - * - * - * 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 { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params; - const { - onChunk, - onReasoningChunk, - onToolCallChunk, - onAttachments, - onModel, - onComplete, - onError, - onTimings - } = callbacks; - - // Get agentic configuration (considering per-chat MCP overrides) - const agenticConfig = agenticStore.getConfig(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 === MessageRole.SYSTEM) { - const content = typeof msg.content === 'string' ? msg.content : ''; - return content.trim().length > 0; - } - return true; - }); - - this.store.setRunning(conversationId, true); - this.store.setCurrentTurn(conversationId, 0); - this.store.setTotalToolCalls(conversationId, 0); - this.store.setLastError(conversationId, null); - - // Acquire reference to prevent premature shutdown while this flow is active - mcpClient.acquireConnection(); - - try { - await this.executeAgenticLoop({ - conversationId, - messages: normalizedMessages, - options, - tools, - agenticConfig, - callbacks: { - onChunk, - onReasoningChunk, - onToolCallChunk, - onAttachments, - onModel, - onComplete, - onError, - onTimings - }, - signal - }); - return { handled: true }; - } catch (error) { - const normalizedError = error instanceof Error ? error : new Error(String(error)); - this.store.setLastError(conversationId, normalizedError); - onError?.(normalizedError); - return { handled: true, error: normalizedError }; - } finally { - this.store.setRunning(conversationId, false); - // Release reference - will only shutdown if no other flows are active - await mcpClient.releaseConnection().catch((err) => { - console.warn('[AgenticClient] Failed to release MCP connection:', err); - }); - } - } - - private async executeAgenticLoop(params: { - conversationId: string; - messages: ApiChatMessageData[]; - options: AgenticFlowOptions; - tools: ReturnType; - agenticConfig: AgenticConfig; - callbacks: AgenticFlowCallbacks; - signal?: AbortSignal; - }): Promise { - const { conversationId, messages, options, tools, agenticConfig, callbacks, signal } = params; - const { - onChunk, - onReasoningChunk, - onToolCallChunk, - onAttachments, - 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(conversationId, turn + 1); - agenticTimings.turns = turn + 1; - - if (signal?.aborted) { - onComplete?.( - '', - undefined, - this.buildFinalTimings(capturedTimings, agenticTimings), - undefined - ); - return; - } - - let turnContent = ''; - let turnToolCalls: ApiChatCompletionToolCall[] = []; - let lastStreamingToolCallName = ''; - let lastStreamingToolCallArgsLength = 0; - - // Track emitted tool call state for progressive streaming - const emittedToolCallStates = new Map(); - - 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, - onToolCallChunk: (serialized: string) => { - try { - turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[]; - - // Emit agentic tags progressively for live UI updates - for (let i = 0; i < turnToolCalls.length; i++) { - const toolCall = turnToolCalls[i]; - const toolName = toolCall.function?.name ?? ''; - const toolArgs = toolCall.function?.arguments ?? ''; - - const state = emittedToolCallStates.get(i) || { - emittedOnce: false, - lastArgs: '' - }; - - if (!state.emittedOnce) { - // First emission: send full header + args - let output = `\n\n<<>>`; - output += `\n<<>>`; - output += `\n<<>>\n`; - output += toolArgs; - onChunk?.(output); - state.emittedOnce = true; - state.lastArgs = toolArgs; - } else if (toolArgs !== state.lastArgs) { - // Subsequent emissions: send only delta - const delta = toolArgs.slice(state.lastArgs.length); - onChunk?.(delta); - state.lastArgs = toolArgs; - } - - emittedToolCallStates.set(i, state); - } - - // 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(conversationId, { 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(conversationId); - - 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(conversationId, allToolCalls.length); - onToolCallChunk?.(JSON.stringify(allToolCalls)); - - sessionMessages.push({ - role: MessageRole.ASSISTANT, - content: turnContent || undefined, - tool_calls: normalizedCalls - }); - - for (const toolCall of normalizedCalls) { - if (signal?.aborted) { - onComplete?.( - '', - undefined, - this.buildFinalTimings(capturedTimings, agenticTimings), - undefined - ); - return; - } - - // Tool call tags were already emitted during streaming via onToolCallChunk - // Start timing for tool execution - const toolStartTime = performance.now(); - - 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 (isAbortError(error)) { - 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; - } - - const { cleanedResult, attachments } = this.extractBase64Attachments(result); - if (attachments.length > 0) { - onAttachments?.(attachments); - } - - this.emitToolCallResult(cleanedResult, maxToolPreviewLines, onChunk); - - // Add tool result to session - // If images were extracted, include them as content parts so the model - // can describe them immediately in the same agentic loop - const contentParts: ApiChatMessageContentPart[] = [{ type: 'text', text: cleanedResult }]; - - for (const attachment of attachments) { - if (attachment.type === AttachmentType.IMAGE) { - contentParts.push({ - type: 'image_url', - image_url: { url: (attachment as DatabaseMessageExtraImageFile).base64Url } - }); - } - } - - sessionMessages.push({ - role: MessageRole.TOOL, - tool_call_id: toolCall.id, - content: contentParts.length === 1 ? cleanedResult : contentParts - }); - } - - // 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 result and end marker. - */ - private emitToolCallResult( - result: string, - maxLines: number, - emit?: (chunk: string) => void - ): void { - if (!emit) return; - - let output = ''; - output += `\n<<>>`; - // 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 extractBase64Attachments(result: string): { - cleanedResult: string; - attachments: DatabaseMessageExtra[]; - } { - if (!result.trim()) { - return { cleanedResult: result, attachments: [] }; - } - - const lines = result.split('\n'); - const attachments: DatabaseMessageExtra[] = []; - let attachmentIndex = 0; - - const cleanedLines = lines.map((line) => { - const trimmedLine = line.trim(); - const match = trimmedLine.match(/^data:([^;]+);base64,([A-Za-z0-9+/]+=*)$/); - if (!match) return line; - - const mimeType = match[1].toLowerCase(); - const base64Data = match[2]; - if (!base64Data) return line; - - attachmentIndex += 1; - const name = this.buildAttachmentName(mimeType, attachmentIndex); - - if (mimeType.startsWith('image/')) { - attachments.push({ - type: AttachmentType.IMAGE, - name, - base64Url: trimmedLine - }); - return `[Attachment saved: ${name}]`; - } - return line; - }); - - return { - cleanedResult: cleanedLines.join('\n'), - attachments - }; - } - - private buildAttachmentName(mimeType: string, index: number): string { - const extensionMap: Record = { - 'image/jpeg': 'jpg', - 'image/jpg': 'jpg', - 'image/png': 'png', - 'image/gif': 'gif', - 'image/webp': 'webp' - }; - - const extension = extensionMap[mimeType] ?? 'img'; - const timestamp = Date.now(); - return `mcp-attachment-${timestamp}-${index}.${extension}`; - } - - clearError(conversationId: string): void { - this.store.setLastError(conversationId, null); - } -} - -export const agenticClient = new AgenticClient(); diff --git a/tools/server/webui/src/lib/clients/base-client.ts b/tools/server/webui/src/lib/clients/base-client.ts deleted file mode 100644 index 7e76b7b5b2..0000000000 --- a/tools/server/webui/src/lib/clients/base-client.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * BaseClient - Abstract base class for client classes - * - * Provides common store callback management pattern used by all clients. - * Clients extend this class to inherit the store callback infrastructure. - * - * **Usage:** - * ```typescript - * interface MyStoreCallbacks { - * setSomeState: (value: string) => void; - * } - * - * class MyClient extends BaseClient { - * doSomething() { - * this.store.setSomeState('value'); - * } - * } - * ``` - */ -export abstract class BaseClient { - private _storeCallbacks: TCallbacks | null = null; - - /** - * Sets callbacks for store state updates. - * Called by the corresponding store during initialization. - */ - setStoreCallbacks(callbacks: TCallbacks): void { - this._storeCallbacks = callbacks; - } - - /** - * Gets the store callbacks, throwing if not initialized. - * Use this in derived classes to access store callbacks safely. - */ - protected get store(): TCallbacks { - if (!this._storeCallbacks) { - throw new Error(`${this.constructor.name}: Store callbacks not initialized`); - } - return this._storeCallbacks; - } - - /** - * Checks if store callbacks have been initialized. - */ - protected get hasStoreCallbacks(): boolean { - return this._storeCallbacks !== null; - } -} diff --git a/tools/server/webui/src/lib/clients/chat.client.ts b/tools/server/webui/src/lib/clients/chat.client.ts deleted file mode 100644 index 12fcd0f134..0000000000 --- a/tools/server/webui/src/lib/clients/chat.client.ts +++ /dev/null @@ -1,1597 +0,0 @@ -import { BaseClient } from './base-client'; -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, - isAbortError -} from '$lib/utils'; -import { agenticStore } from '$lib/stores/agentic.svelte'; -import { DEFAULT_CONTEXT } from '$lib/constants/default-context'; -import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui'; -import { REASONING_TAGS } from '$lib/constants/agentic'; -import type { - ChatMessageTimings, - ChatMessagePromptProgress, - ChatStreamCallbacks, - ErrorDialogState -} from '$lib/types/chat'; -import type { DatabaseMessage, DatabaseMessageExtra } from '$lib/types/database'; -import type { ApiProcessingState } from '$lib/types/api'; -import { MessageRole, MessageType } from '$lib/enums'; - -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; -} - -const countOccurrences = (source: string, token: string): number => - source ? source.split(token).length - 1 : 0; - -const hasUnclosedReasoningTag = (content: string): boolean => - countOccurrences(content, REASONING_TAGS.START) > countOccurrences(content, REASONING_TAGS.END); - -const wrapReasoningContent = (content: string, reasoningContent?: string): string => { - if (!reasoningContent) return content; - return `${REASONING_TAGS.START}${reasoningContent}${REASONING_TAGS.END}${content}`; -}; - -/** - * 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 extends BaseClient { - /** - * - * - * Message Operations - * - * - */ - - private getMessageByIdWithRole( - messageId: string, - expectedRole?: MessageRole - ): { 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 - * @throws Error if no active conversation or database operation fails - */ - async addMessage( - role: MessageRole, - content: string, - type: MessageType = MessageType.TEXT, - parent: string = '-1', - extras?: DatabaseMessageExtra[] - ): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) { - throw new Error('No active conversation when trying to add message'); - } - - 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(), - toolCalls: '', - children: [], - extra: extras - }, - parentId - ); - - conversationsStore.addMessageToActive(message); - await conversationsStore.updateCurrentNode(message.id); - conversationsStore.updateConversationTimestamp(); - - return message; - } - - /** - * 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 === MessageRole.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 !== MessageRole.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) { - throw new Error('No active conversation when creating assistant message'); - } - - return await DatabaseService.createMessageBranch( - { - convId: activeConv.id, - type: 'text', - role: MessageRole.ASSISTANT, - content: '', - timestamp: Date.now(), - 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 { - let parentIdForUserMessage: string | undefined; - - 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); - parentIdForUserMessage = systemMessage.id; - } else { - // No system prompt - user message should be child of root - parentIdForUserMessage = rootId; - } - } - - const userMessage = await this.addMessage( - MessageRole.USER, - content, - MessageType.TEXT, - parentIdForUserMessage ?? '-1', - extras - ); - if (isNewConversation && content) - await conversationsStore.updateConversationName(currentConv.id, content.trim()); - - const assistantMessage = await this.createAssistantMessage(userMessage.id); - - conversationsStore.addMessageToActive(assistantMessage); - await this.streamChatCompletion( - conversationsStore.activeMessages.slice(0, -1), - assistantMessage - ); - } catch (error) { - if (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 streamedToolCallContent = ''; - let isReasoningOpen = false; - let hasStreamedChunks = false; - let resolvedModel: string | null = null; - let modelPersisted = false; - let streamedExtras: DatabaseMessageExtra[] = assistantMessage.extra - ? JSON.parse(JSON.stringify(assistantMessage.extra)) - : []; - - 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; - }); - } - }; - - const updateStreamingContent = () => { - this.store.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id); - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); - }; - - const appendContentChunk = (chunk: string) => { - if (isReasoningOpen) { - streamedContent += REASONING_TAGS.END; - isReasoningOpen = false; - } - streamedContent += chunk; - hasStreamedChunks = true; - updateStreamingContent(); - }; - - const appendReasoningChunk = (chunk: string) => { - if (!isReasoningOpen) { - streamedContent += REASONING_TAGS.START; - isReasoningOpen = true; - } - streamedContent += chunk; - hasStreamedChunks = true; - updateStreamingContent(); - }; - - const finalizeReasoning = () => { - if (isReasoningOpen) { - streamedContent += REASONING_TAGS.END; - isReasoningOpen = false; - } - }; - - this.store.setStreamingActive(true); - this.store.setActiveProcessingConversation(assistantMessage.convId); - - const abortController = this.store.getAbortController(assistantMessage.convId); - - const streamCallbacks: ChatStreamCallbacks = { - onChunk: (chunk: string) => { - appendContentChunk(chunk); - }, - onReasoningChunk: (reasoningChunk: string) => { - appendReasoningChunk(reasoningChunk); - }, - onToolCallChunk: (toolCallChunk: string) => { - const chunk = toolCallChunk.trim(); - if (!chunk) return; - streamedToolCallContent = chunk; - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent }); - }, - onAttachments: (extras: DatabaseMessageExtra[]) => { - if (!extras.length) return; - streamedExtras = [...streamedExtras, ...extras]; - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - conversationsStore.updateMessageAtIndex(idx, { extra: streamedExtras }); - DatabaseService.updateMessage(assistantMessage.id, { extra: streamedExtras }).catch( - console.error - ); - }, - 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); - finalizeReasoning(); - - const combinedContent = hasStreamedChunks - ? streamedContent - : wrapReasoningContent(finalContent || '', reasoningContent); - - const updateData: Record = { - content: combinedContent, - toolCalls: toolCallContent || streamedToolCallContent, - timings - }; - if (streamedExtras.length > 0) { - updateData.extra = streamedExtras; - } - if (resolvedModel && !modelPersisted) { - updateData.model = resolvedModel; - } - await DatabaseService.updateMessage(assistantMessage.id, updateData); - - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - const uiUpdate: Partial = { - content: combinedContent, - toolCalls: updateData.toolCalls as string - }; - if (streamedExtras.length > 0) { - uiUpdate.extra = streamedExtras; - } - if (timings) uiUpdate.timings = timings; - if (resolvedModel) uiUpdate.model = resolvedModel; - - conversationsStore.updateMessageAtIndex(idx, uiUpdate); - await conversationsStore.updateCurrentNode(assistantMessage.id); - - if (onComplete) await onComplete(combinedContent); - 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 (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 = agenticStore.getConfig(config(), perChatOverrides); - if (agenticConfig.enabled) { - const agenticResult = await agenticClient.runAgenticFlow({ - conversationId: assistantMessage.convId, - 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 === MessageRole.ASSISTANT) { - try { - const updateData: { content: string; timings?: ChatMessageTimings } = { - content: streamingState.response - }; - 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.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, MessageRole.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(); - - 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 (!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, MessageRole.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); - conversationsStore.addMessageToActive(assistantMessage); - await this.streamChatCompletion( - conversationsStore.activeMessages.slice(0, -1), - assistantMessage - ); - } catch (error) { - if (!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 !== MessageRole.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: '', - 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 (!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 === MessageRole.USER) { - userMessages++; - if (!messageTypes.includes('user message')) messageTypes.push('user message'); - } else if (msg.role === MessageRole.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, MessageRole.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 conversationContext = conversationsStore.activeMessages.slice(0, idx); - const contextWithContinue = [ - ...conversationContext, - { role: MessageRole.ASSISTANT as const, content: originalContent } - ]; - - let appendedContent = ''; - let hasReceivedContent = false; - let isReasoningOpen = hasUnclosedReasoningTag(originalContent); - - const updateStreamingContent = (fullContent: string) => { - this.store.setChatStreaming(msg.convId, fullContent, msg.id); - conversationsStore.updateMessageAtIndex(idx, { content: fullContent }); - }; - - const appendContentChunk = (chunk: string) => { - if (isReasoningOpen) { - appendedContent += REASONING_TAGS.END; - isReasoningOpen = false; - } - appendedContent += chunk; - hasReceivedContent = true; - updateStreamingContent(originalContent + appendedContent); - }; - - const appendReasoningChunk = (chunk: string) => { - if (!isReasoningOpen) { - appendedContent += REASONING_TAGS.START; - isReasoningOpen = true; - } - appendedContent += chunk; - hasReceivedContent = true; - updateStreamingContent(originalContent + appendedContent); - }; - - const finalizeReasoning = () => { - if (isReasoningOpen) { - appendedContent += REASONING_TAGS.END; - isReasoningOpen = false; - } - }; - - const abortController = this.store.getAbortController(msg.convId); - - await ChatService.sendMessage( - contextWithContinue, - { - ...this.getApiOptions(), - - onChunk: (chunk: string) => { - appendContentChunk(chunk); - }, - - onReasoningChunk: (reasoningChunk: string) => { - appendReasoningChunk(reasoningChunk); - }, - - 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 - ) => { - finalizeReasoning(); - const appendedFromCompletion = hasReceivedContent - ? appendedContent - : wrapReasoningContent(finalContent || '', reasoningContent); - const fullContent = originalContent + appendedFromCompletion; - await DatabaseService.updateMessage(msg.id, { - content: fullContent, - timestamp: Date.now(), - timings - }); - conversationsStore.updateMessageAtIndex(idx, { - content: fullContent, - 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 (isAbortError(error)) { - if (hasReceivedContent && appendedContent) { - await DatabaseService.updateMessage(msg.id, { - content: originalContent + appendedContent, - timestamp: Date.now() - }); - conversationsStore.updateMessageAtIndex(idx, { - content: originalContent + appendedContent, - 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 - }); - await DatabaseService.updateMessage(msg.id, { - content: originalContent - }); - 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 (!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, MessageRole.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, - 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, MessageRole.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, MessageRole.USER); - - if (!result) { - result = this.getMessageByIdWithRole(messageId, MessageRole.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 === MessageRole.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, - 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 === MessageRole.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: MessageRole.ASSISTANT, - content: '', - 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 === MessageRole.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 === MessageRole.ASSISTANT && message.model) { - return message.model; - } - } - return null; - } - - /** - * - * - * Utilities - * - * - */ - - 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 deleted file mode 100644 index b62a64a7c1..0000000000 --- a/tools/server/webui/src/lib/clients/conversations.client.ts +++ /dev/null @@ -1,718 +0,0 @@ -/** - * 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 { BaseClient } from './base-client'; -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 { mcpClient } from '$lib/clients/mcp.client'; -import { mcpStore } from '$lib/stores/mcp.svelte'; -import type { McpServerOverride } from '$lib/types/database'; -import { MessageRole } from '$lib/enums'; - -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 extends BaseClient { - /** - * - * - * 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); - } - - // Run MCP health checks for enabled servers in this conversation - this.runMcpHealthChecksForConversation(conversation.mcpServerOverrides); - - return true; - } catch (error) { - console.error('Failed to load conversation:', error); - return false; - } - } - - /** - * Runs MCP health checks for servers enabled in a conversation. - * Runs asynchronously in the background without blocking conversation loading. - * @param mcpServerOverrides - The conversation's MCP server overrides - */ - private runMcpHealthChecksForConversation(mcpServerOverrides?: McpServerOverride[]): void { - if (!mcpServerOverrides?.length) { - return; - } - - const enabledServers = mcpStore.getEnabledServersForConversation(mcpServerOverrides); - - if (enabledServers.length === 0) { - return; - } - - console.log( - `[ConversationsClient] Running health checks for ${enabledServers.length} MCP server(s)` - ); - - // Run health checks in background (don't await) - mcpClient.runHealthChecksForServers(enabledServers).catch((error) => { - console.warn('[ConversationsClient] MCP health checks failed:', error); - }); - } - - /** - * - * - * 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 === MessageRole.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 === MessageRole.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. - * @param serverId - The server ID to check - * @returns True if server is enabled for this conversation - */ - isMcpServerEnabledForChat(serverId: string): boolean { - const override = this.getMcpServerOverride(serverId); - return override?.enabled ?? false; - } - - /** - * 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 - */ - async toggleMcpServerForChat(serverId: string): Promise { - const currentEnabled = this.isMcpServerEnabledForChat(serverId); - await this.setMcpServerOverride(serverId, !currentEnabled); - } - - /** - * Removes MCP server override for the active conversation. - * @param serverId - The server ID to remove override for - */ - async removeMcpServerOverride(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 deleted file mode 100644 index 0ffe5f4a02..0000000000 --- a/tools/server/webui/src/lib/clients/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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 - */ - -// Base Client -export { BaseClient } from './base-client'; - -// MCP Client -export { MCPClient, mcpClient } from './mcp.client'; - -// Chat Client -export { ChatClient, chatClient } from './chat.client'; - -// Agentic Client -export { AgenticClient, agenticClient } 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 deleted file mode 100644 index e01cf253f7..0000000000 --- a/tools/server/webui/src/lib/clients/mcp.client.ts +++ /dev/null @@ -1,1051 +0,0 @@ -/** - * 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 { mcpStore } from '$lib/stores/mcp.svelte'; -import { browser } from '$app/environment'; -import { MCPService } from '$lib/services/mcp.service'; -import type { - MCPToolCall, - OpenAIToolDefinition, - ServerStatus, - ToolExecutionResult, - MCPClientConfig, - MCPConnection, - HealthCheckParams, - ServerCapabilities, - ClientCapabilities, - MCPCapabilitiesInfo, - MCPConnectionLog, - MCPPromptInfo, - GetPromptResult, - Tool, - Prompt -} from '$lib/types'; -import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js'; -import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums'; -import type { McpServerOverride } from '$lib/types/database'; -import { detectMcpTransportFromUrl } from '$lib/utils'; -import { config } from '$lib/stores/settings.svelte'; -import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp'; -import type { MCPServerConfig, MCPServerSettingsEntry } from '$lib/types'; -import type { SettingsConfigType } from '$lib/types/settings'; - -/** - * Generates a valid MCP server ID from user input. - * Returns the trimmed ID if valid, otherwise generates 'server-{index+1}'. - */ -function generateMcpServerId(id: unknown, index: number): string { - if (typeof id === 'string' && id.trim()) { - return id.trim(); - } - - return `${MCP_SERVER_ID_PREFIX}${index + 1}`; -} - -/** - * Parses MCP server settings from a JSON string or array. - * requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value. - * @param rawServers - The raw servers to parse - * @returns An empty array if the input is invalid. - */ -function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEntry[] { - if (!rawServers) return []; - - let parsed: unknown; - if (typeof rawServers === 'string') { - const trimmed = rawServers.trim(); - if (!trimmed) return []; - - try { - parsed = JSON.parse(trimmed); - } catch (error) { - console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error); - return []; - } - } else { - parsed = rawServers; - } - - if (!Array.isArray(parsed)) return []; - - return parsed.map((entry, index) => { - const url = typeof entry?.url === 'string' ? entry.url.trim() : ''; - const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined; - - return { - id: generateMcpServerId((entry as { id?: unknown })?.id, index), - enabled: Boolean((entry as { enabled?: unknown })?.enabled), - url, - requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, - headers: headers || undefined - } satisfies MCPServerSettingsEntry; - }); -} - -/** - * Builds an MCP server configuration from a server settings entry. - * @param entry - The server settings entry to build the configuration from - * @param connectionTimeoutMs - The connection timeout in milliseconds - * @returns The built server configuration, or undefined if the entry is invalid - */ -function buildServerConfig( - entry: MCPServerSettingsEntry, - connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs -): MCPServerConfig | undefined { - if (!entry?.url) { - return undefined; - } - - let headers: Record | undefined; - if (entry.headers) { - try { - const parsed = JSON.parse(entry.headers); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - headers = parsed as Record; - } - } catch { - console.warn('[MCP] Failed to parse custom headers JSON, ignoring:', entry.headers); - } - } - - return { - url: entry.url, - transport: detectMcpTransportFromUrl(entry.url), - handshakeTimeoutMs: connectionTimeoutMs, - requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000), - headers - }; -} - -/** - * Checks if a server is enabled for the current chat. - * Server must be available (server.enabled) AND have a per-chat override enabling it. - * Pure helper function - no side effects. - */ -function checkServerEnabled( - server: MCPServerSettingsEntry, - perChatOverrides?: McpServerOverride[] -): boolean { - if (!server.enabled) { - return false; - } - - if (perChatOverrides) { - const override = perChatOverrides.find((o) => o.serverId === server.id); - return override?.enabled ?? false; - } - - return false; -} - -/** - * Builds MCP client configuration from settings. - * Returns undefined if no valid servers are configured. - * @param config - Global settings configuration - * @param perChatOverrides - Optional per-chat server overrides - */ -export function buildMcpClientConfig( - config: SettingsConfigType, - perChatOverrides?: McpServerOverride[] -): MCPClientConfig | undefined { - const rawServers = parseMcpServerSettings(config.mcpServers); - - if (!rawServers.length) { - return undefined; - } - - const servers: Record = {}; - for (const [index, entry] of rawServers.entries()) { - if (!checkServerEnabled(entry, perChatOverrides)) continue; - - const normalized = buildServerConfig(entry); - if (normalized) { - servers[generateMcpServerId(entry.id, index)] = normalized; - } - } - - if (Object.keys(servers).length === 0) { - return undefined; - } - - return { - protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, - capabilities: DEFAULT_MCP_CONFIG.capabilities, - clientInfo: DEFAULT_MCP_CONFIG.clientInfo, - requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000), - servers - }; -} - -/** - * Build capabilities info from server and client capabilities - */ -function buildCapabilitiesInfo( - serverCaps?: ServerCapabilities, - clientCaps?: ClientCapabilities -): MCPCapabilitiesInfo { - return { - server: { - tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined, - prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined, - resources: serverCaps?.resources - ? { - subscribe: serverCaps.resources.subscribe, - listChanged: serverCaps.resources.listChanged - } - : undefined, - logging: !!serverCaps?.logging, - completions: !!serverCaps?.completions, - tasks: !!serverCaps?.tasks - }, - client: { - roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined, - sampling: !!clientCaps?.sampling, - elicitation: clientCaps?.elicitation - ? { - form: !!clientCaps.elicitation.form, - url: !!clientCaps.elicitation.url - } - : undefined, - tasks: !!clientCaps?.tasks - } - }; -} - -export class MCPClient { - private connections = new Map(); - private toolsIndex = new Map(); - private configSignature: string | null = null; - private initPromise: Promise | null = null; - - /** - * Reference counter for active agentic flows using MCP connections. - * Prevents shutdown while any conversation is still using connections. - */ - private activeFlowCount = 0; - - /** - * 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...'); - - mcpStore.updateState({ isInitializing: true, error: null }); - this.configSignature = signature; - - const serverEntries = Object.entries(mcpConfig.servers); - if (serverEntries.length === 0) { - console.log('[MCPClient] No servers configured'); - mcpStore.updateState({ 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 listChangedHandlers = this.createListChangedHandlers(name); - const connection = await MCPService.connect( - name, - serverConfig, - clientInfo, - capabilities, - undefined, - listChangedHandlers - ); - - 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'; - mcpStore.updateState({ - isInitializing: false, - error, - toolCount: 0, - connectedServers: [] - }); - this.initPromise = null; - return false; - } - - mcpStore.updateState({ - 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; - } - - /** - * Create list changed handlers for a server connection. - * These handlers are called when the server notifies about changes to tools, prompts, or resources. - */ - private createListChangedHandlers(serverName: string): ListChangedHandlers { - return { - tools: { - onChanged: (error: Error | null, tools: Tool[] | null) => { - if (error) { - console.warn(`[MCPClient][${serverName}] Tools list changed error:`, error); - return; - } - console.log(`[MCPClient][${serverName}] Tools list changed, ${tools?.length ?? 0} tools`); - this.handleToolsListChanged(serverName, tools ?? []); - } - }, - prompts: { - onChanged: (error: Error | null, prompts: Prompt[] | null) => { - if (error) { - console.warn(`[MCPClient][${serverName}] Prompts list changed error:`, error); - return; - } - console.log( - `[MCPClient][${serverName}] Prompts list changed, ${prompts?.length ?? 0} prompts` - ); - this.handlePromptsListChanged(serverName); - } - } - }; - } - - /** - * Handle tools list changed notification from a server. - * Updates the tools index and store. - */ - private handleToolsListChanged(serverName: string, tools: Tool[]): void { - const connection = this.connections.get(serverName); - if (!connection) return; - - // Remove old tools from this server from the index - for (const [toolName, ownerServer] of this.toolsIndex.entries()) { - if (ownerServer === serverName) { - this.toolsIndex.delete(toolName); - } - } - - // Update connection tools - connection.tools = tools; - - // Add new tools to the index - for (const tool of tools) { - if (this.toolsIndex.has(tool.name)) { - console.warn( - `[MCPClient] Tool name conflict after list change: "${tool.name}" exists in ` + - `"${this.toolsIndex.get(tool.name)}" and "${serverName}". ` + - `Using tool from "${serverName}".` - ); - } - this.toolsIndex.set(tool.name, serverName); - } - - // Update store - mcpStore.updateState({ - toolCount: this.toolsIndex.size - }); - } - - /** - * Handle prompts list changed notification from a server. - * Triggers a refresh of the prompts cache if needed. - */ - private handlePromptsListChanged(serverName: string): void { - // Prompts are fetched on-demand, so we just log the change - // The UI will get fresh prompts on next getAllPrompts() call - console.log( - `[MCPClient][${serverName}] Prompts list updated - will be refreshed on next fetch` - ); - } - - /** - * Acquire a reference to MCP connections for an agentic flow. - * Call this when starting an agentic flow to prevent premature shutdown. - */ - acquireConnection(): void { - this.activeFlowCount++; - console.log(`[MCPClient] Connection acquired (active flows: ${this.activeFlowCount})`); - } - - /** - * Release a reference to MCP connections. - * Call this when an agentic flow completes. - * @param shutdownIfUnused - If true, shutdown connections when no flows are active - */ - async releaseConnection(shutdownIfUnused = true): Promise { - this.activeFlowCount = Math.max(0, this.activeFlowCount - 1); - console.log(`[MCPClient] Connection released (active flows: ${this.activeFlowCount})`); - - if (shutdownIfUnused && this.activeFlowCount === 0) { - console.log('[MCPClient] No active flows, initiating lazy disconnect...'); - await this.shutdown(); - } - } - - /** - * Get the number of active agentic flows using MCP connections. - */ - getActiveFlowCount(): number { - return this.activeFlowCount; - } - - /** - * Shutdown all MCP connections and clear state. - * Note: This will force shutdown regardless of active flow count. - */ - 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; - - mcpStore.updateState({ - 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); - } - - /** - * - * - * Prompts - * - * - */ - - /** - * Get all prompts from all connected servers that support prompts. - */ - async getAllPrompts(): Promise { - const results: MCPPromptInfo[] = []; - - for (const [serverName, connection] of this.connections) { - if (!connection.serverCapabilities?.prompts) continue; - - const prompts = await MCPService.listPrompts(connection); - for (const prompt of prompts) { - results.push({ - name: prompt.name, - description: prompt.description, - title: prompt.title, - serverName, - arguments: prompt.arguments?.map((arg) => ({ - name: arg.name, - description: arg.description, - required: arg.required - })) - }); - } - } - - return results; - } - - /** - * Get a prompt by name from a specific server. - * Returns the prompt messages ready to be used in chat. - * Throws an error if the server is not found or prompt execution fails. - */ - async getPrompt( - serverName: string, - promptName: string, - args?: Record - ): Promise { - const connection = this.connections.get(serverName); - - if (!connection) { - const errorMsg = `Server "${serverName}" not found for prompt "${promptName}"`; - console.error(`[MCPClient] ${errorMsg}`); - - throw new Error(errorMsg); - } - - return MCPService.getPrompt(connection, promptName, args); - } - - /** - * Check if any connected server supports prompts. - */ - hasPromptsSupport(): boolean { - for (const connection of this.connections.values()) { - if (connection.serverCapabilities?.prompts) { - return true; - } - } - - return false; - } - - /** - * - * - * 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 Error(`Unknown tool: ${toolName}`); - } - - const connection = this.connections.get(serverName); - if (!connection) { - throw new Error(`Server "${serverName}" is not connected`); - } - - 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 Error(`Unknown tool: ${toolName}`); - } - - const connection = this.connections.get(serverName); - if (!connection) { - throw new Error(`Server "${serverName}" is not connected`); - } - - 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 Error( - `Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}` - ); - } - return parsed as Record; - } catch (error) { - throw new Error(`Failed to parse tool arguments as JSON: ${(error as Error).message}`); - } - } - - if (typeof args === 'object' && args !== null && !Array.isArray(args)) { - return args; - } - - throw new Error(`Invalid tool arguments type: ${typeof args}`); - } - - /** - * - * - * Completions - * - * - */ - - /** - * Get completion suggestions for a prompt argument. - * Used for autocompleting prompt argument values. - * - * @param serverName - Name of the server hosting the prompt - * @param promptName - Name of the prompt - * @param argumentName - Name of the argument being completed - * @param argumentValue - Current partial value of the argument - * @returns Completion suggestions or null if not supported/error - */ - async getPromptCompletions( - serverName: string, - promptName: string, - argumentName: string, - argumentValue: string - ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> { - const connection = this.connections.get(serverName); - if (!connection) { - console.warn(`[MCPClient] Server "${serverName}" is not connected`); - return null; - } - - if (!connection.serverCapabilities?.completions) { - return null; - } - - return MCPService.complete( - connection, - { type: 'ref/prompt', name: promptName }, - { name: argumentName, value: argumentValue } - ); - } - - /** - * - * - * 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 checks for multiple servers that don't have a recent check. - * Useful for lazy-loading health checks when UI is opened. - * @param servers - Array of servers to check - * @param skipIfChecked - If true, skip servers that already have a health check result - */ - async runHealthChecksForServers( - servers: { - id: string; - enabled: boolean; - url: string; - requestTimeoutSeconds: number; - headers?: string; - }[], - skipIfChecked = true - ): Promise { - const serversToCheck = skipIfChecked - ? servers.filter((s) => !mcpStore.hasHealthCheck(s.id) && s.url.trim()) - : servers.filter((s) => s.url.trim()); - - if (serversToCheck.length === 0) return; - - const BATCH_SIZE = 5; - - for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) { - const batch = serversToCheck.slice(i, i + BATCH_SIZE); - await Promise.all(batch.map((server) => this.runHealthCheck(server))); - } - } - - /** - * Run health check for a specific server configuration. - * Creates a temporary connection to test connectivity and list tools. - * Tracks connection phases and collects detailed connection info. - */ - async runHealthCheck(server: HealthCheckParams): Promise { - const trimmedUrl = server.url.trim(); - const logs: MCPConnectionLog[] = []; - let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE; - - if (!trimmedUrl) { - mcpStore.updateHealthCheck(server.id, { - status: HealthCheckStatus.ERROR, - message: 'Please enter a server URL first.', - logs: [] - }); - return; - } - - // Initial connecting state - mcpStore.updateHealthCheck(server.id, { - status: HealthCheckStatus.CONNECTING, - phase: MCPConnectionPhase.TRANSPORT_CREATING, - logs: [] - }); - - 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, - // Phase callback for tracking progress - (phase, log) => { - currentPhase = phase; - logs.push(log); - mcpStore.updateHealthCheck(server.id, { - status: HealthCheckStatus.CONNECTING, - phase, - logs: [...logs] - }); - } - ); - - const tools = connection.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - title: tool.title - })); - - const capabilities = buildCapabilitiesInfo( - connection.serverCapabilities, - connection.clientCapabilities - ); - - mcpStore.updateHealthCheck(server.id, { - status: HealthCheckStatus.SUCCESS, - tools, - serverInfo: connection.serverInfo, - capabilities, - transportType: connection.transportType, - protocolVersion: connection.protocolVersion, - instructions: connection.instructions, - connectionTimeMs: connection.connectionTimeMs, - logs - }); - - await MCPService.disconnect(connection); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error occurred'; - logs.push({ - timestamp: new Date(), - phase: MCPConnectionPhase.ERROR, - message: `Connection failed: ${message}`, - level: MCPLogLevel.ERROR - }); - mcpStore.updateHealthCheck(server.id, { - status: HealthCheckStatus.ERROR, - message, - phase: currentPhase, - logs - }); - } - } - - /** - * - * - * 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/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte index 771ffb87b3..135008c251 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte @@ -1,5 +1,4 @@