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 @@