refactor: Go back to simpler Stores + Services architecture

This commit is contained in:
Aleksander Grygier 2026-01-27 15:57:12 +01:00
parent f7b7ae467e
commit aff13cc085
16 changed files with 2967 additions and 4866 deletions

Binary file not shown.

View File

@ -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<AgenticStoreStateCallbacks> {
/**
*
*
* 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<AgenticFlowResult> {
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<typeof mcpClient.getToolDefinitionsForLLM>;
agenticConfig: AgenticConfig;
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
}): Promise<void> {
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<number, { emittedOnce: boolean; lastArgs: string }>();
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<<<AGENTIC_TOOL_CALL_START>>>`;
output += `\n<<<TOOL_NAME:${toolName}>>>`;
output += `\n<<<TOOL_ARGS_START>>>\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<<<TOOL_ARGS_END>>>`;
// 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<<<AGENTIC_TOOL_CALL_END>>>\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<string, string> = {
'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();

View File

@ -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<MyStoreCallbacks> {
* doSomething() {
* this.store.setSomeState('value');
* }
* }
* ```
*/
export abstract class BaseClient<TCallbacks> {
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<boolean>)
| undefined;
}
export class ConversationsClient extends BaseClient<ConversationsStoreStateCallbacks> {
/**
*
*
* Lifecycle
*
*
*/
/**
* Initializes the conversations by loading from the database.
*/
async initialize(): Promise<void> {
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<void> {
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<string> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<DatabaseMessage[]> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<DatabaseConversation[]> {
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<DatabaseConversation[]> {
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();

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
<script lang="ts">
import { mcpClient } from '$lib/clients/mcp.client';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { debounce } from '$lib/utils';
@ -81,14 +80,14 @@
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpClient.ensureInitialized(perChatOverrides);
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
prompts = [];
return;
}
prompts = await mcpClient.getAllPrompts();
prompts = await mcpStore.getAllPrompts();
} catch (error) {
console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
prompts = [];
@ -123,7 +122,7 @@
onClose?.();
try {
const result = await mcpClient.getPrompt(prompt.serverName, prompt.name, args);
const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
onPromptLoadComplete?.(placeholderId, result);
} catch (error) {
const errorMessage =
@ -158,7 +157,7 @@
loadingSuggestions[argName] = true;
try {
const result = await mcpClient.getPromptCompletions(
const result = await mcpStore.getPromptCompletions(
selectedPrompt.serverName,
selectedPrompt.name,
argName,

View File

@ -2,7 +2,6 @@
import * as Dialog from '$lib/components/ui/dialog';
import { McpLogo, McpServersSettings } from '$lib/components/app';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpClient } from '$lib/clients/mcp.client';
interface Props {
onOpenChange?: (open: boolean) => void;
@ -13,7 +12,7 @@
$effect(() => {
if (open) {
mcpClient.runHealthChecksForServers(mcpStore.getServers());
mcpStore.runHealthChecksForServers(mcpStore.getServers());
}
});

View File

@ -5,7 +5,6 @@
import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
import { HealthCheckStatus } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpClient } from '$lib/clients/mcp.client';
import {
McpServerCardActions,
McpServerCardDeleteDialog,
@ -62,7 +61,7 @@
let editFormRef: McpServerCardEditForm | null = $state(null);
function handleHealthCheck() {
mcpClient.runHealthCheck(server);
mcpStore.runHealthCheck(server);
}
async function startEditing() {
@ -87,7 +86,7 @@
isEditing = false;
if (server.enabled && url) {
setTimeout(() => mcpClient.runHealthCheck({ ...server, url }), 100);
setTimeout(() => mcpStore.runHealthCheck({ ...server, url }), 100);
}
}

View File

@ -5,7 +5,6 @@
import { DropdownMenuSearchable, McpActiveServersAvatars } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpClient } from '$lib/clients/mcp.client';
import { getFaviconUrl } from '$lib/utils';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
@ -55,7 +54,7 @@
function handleDropdownOpen(open: boolean) {
if (open) {
mcpClient.runHealthChecksForServers(mcpServers);
mcpStore.runHealthChecksForServers(mcpServers);
}
}

View File

@ -176,7 +176,7 @@ export { default as McpLogo } from './McpLogo.svelte';
* - Composes header, tools list, logs, and actions sub-components
* - Manages local edit/delete state
* - Reads health state from mcpStore
* - Triggers health checks via mcpClient
* - Triggers health checks via mcpStore
*
* **Features:**
* - Server header with favicon, name, version, and toggle

View File

@ -1,46 +1,61 @@
/**
* agenticStore - Reactive State Store for Agentic Loop
* agenticStore - Reactive State Store for Agentic Loop Orchestration
*
* This store contains ONLY reactive state ($state).
* All business logic is delegated to AgenticClient.
* Manages multi-turn agentic loop with MCP tools:
* - LLM streaming with tool call detection
* - Tool execution via mcpStore
* - Session state management
* - Turn limit enforcement
*
* **Architecture & Relationships:**
* - **AgenticClient**: Business logic facade (loop orchestration, tool execution)
* - **MCPClient**: Tool execution via MCP servers
* - **agenticStore** (this): Reactive state for UI components
* - **ChatService**: Stateless API layer (sendMessage, streaming)
* - **mcpStore**: MCP connection management and tool execution
* - **agenticStore** (this): Reactive state + business logic
*
* **Responsibilities:**
* - Hold per-conversation reactive state for UI binding
* - Provide getters for computed values (scoped by conversationId)
* - Expose setters for AgenticClient to update state
* - Forward method calls to AgenticClient
* - Track sampling requests for debugging
*
* **Per-Conversation Architecture:**
* - Each conversation has its own AgenticSession
* - Parallel agentic flows in different chats don't interfere
* - Sessions are created on-demand and cleaned up when done
*
* @see AgenticClient in clients/agentic/ for business logic
* @see MCPClient in clients/mcp/ for tool execution
* @see ChatService in services/chat.service.ts for API operations
* @see mcpStore in stores/mcp.svelte.ts for MCP operations
*/
import { browser } from '$app/environment';
import { SvelteMap } from 'svelte/reactivity';
import { ChatService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { isAbortError } from '$lib/utils';
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
import { AttachmentType, MessageRole } from '$lib/enums';
import type {
AgenticFlowParams,
AgenticFlowResult,
AgenticSession,
AgenticConfig,
SettingsConfigType,
McpServerOverride
McpServerOverride,
MCPToolCall
} from '$lib/types';
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { agenticClient } from '$lib/clients/agentic.client';
import type {
AgenticMessage,
AgenticToolCallList,
AgenticFlowCallbacks,
AgenticFlowOptions
} 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 {
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraImageFile
} from '$lib/types/database';
/**
* Creates a fresh agentic session with default values.
*/
function createDefaultSession(): AgenticSession {
return {
isRunning: false,
@ -51,56 +66,50 @@ function createDefaultSession(): AgenticSession {
};
}
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;
});
}
class AgenticStore {
/**
* Per-conversation agentic sessions.
* Key is conversationId, value is the session state.
*/
private _sessions = $state<Map<string, AgenticSession>>(new Map());
/** Reference to the client */
private _client = agenticClient;
private get client() {
return this._client;
}
/** Check if store is ready (client initialized) */
get isReady(): boolean {
return this._initialized;
return true;
}
get isAnyRunning(): boolean {
for (const session of this._sessions.values()) {
if (session.isRunning) return true;
}
return false;
}
private _initialized = false;
/**
* Initialize the store by wiring up to the client.
* Must be called once after app startup.
*/
init(): void {
if (!browser) return;
if (this._initialized) return; // Already initialized
agenticClient.setStoreCallbacks({
setRunning: (convId, running) => this.updateSession(convId, { isRunning: running }),
setCurrentTurn: (convId, turn) => this.updateSession(convId, { currentTurn: turn }),
setTotalToolCalls: (convId, count) => this.updateSession(convId, { totalToolCalls: count }),
setLastError: (convId, error) => this.updateSession(convId, { lastError: error }),
setStreamingToolCall: (convId, tc) => this.updateSession(convId, { streamingToolCall: tc }),
clearStreamingToolCall: (convId) => this.updateSession(convId, { streamingToolCall: null })
});
this._initialized = true;
}
/**
*
* Session Management
*
*/
/**
* Get session for a conversation, creating if needed.
*/
getSession(conversationId: string): AgenticSession {
let session = this._sessions.get(conversationId);
if (!session) {
@ -109,167 +118,535 @@ class AgenticStore {
}
return session;
}
/**
* Update session state for a conversation.
*/
private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
const session = this.getSession(conversationId);
const updated = { ...session, ...update };
this._sessions.set(conversationId, updated);
this._sessions.set(conversationId, { ...session, ...update });
}
/**
* Clear session for a conversation.
*/
clearSession(conversationId: string): void {
this._sessions.delete(conversationId);
}
/**
* Get all active sessions (conversations with running agentic flows).
*/
getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> {
const active: Array<{ conversationId: string; session: AgenticSession }> = [];
for (const [conversationId, session] of this._sessions.entries()) {
if (session.isRunning) {
active.push({ conversationId, session });
}
if (session.isRunning) active.push({ conversationId, session });
}
return active;
}
/**
*
* Convenience Getters (for current/active conversation)
*
*/
/**
* Check if any agentic flow is running (global).
*/
get isAnyRunning(): boolean {
for (const session of this._sessions.values()) {
if (session.isRunning) return true;
}
return false;
}
/**
* Get running state for a specific conversation.
*/
isRunning(conversationId: string): boolean {
return this.getSession(conversationId).isRunning;
}
/**
* Get current turn for a specific conversation.
*/
currentTurn(conversationId: string): number {
return this.getSession(conversationId).currentTurn;
}
/**
* Get total tool calls for a specific conversation.
*/
totalToolCalls(conversationId: string): number {
return this.getSession(conversationId).totalToolCalls;
}
/**
* Get last error for a specific conversation.
*/
lastError(conversationId: string): Error | null {
return this.getSession(conversationId).lastError;
}
/**
* Get streaming tool call for a specific conversation.
*/
streamingToolCall(conversationId: string): { name: string; arguments: string } | null {
return this.getSession(conversationId).streamingToolCall;
}
/**
*
* Agentic Flow Execution
*
*/
/**
* Run the agentic orchestration loop with MCP tools.
* Delegates to AgenticClient.
*/
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
if (!this.client) {
throw new Error('AgenticStore not initialized. Call init() first.');
}
return this.client.runAgenticFlow(params);
}
/**
* Clear error state for a conversation.
*/
clearError(conversationId: string): void {
this.updateSession(conversationId, { lastError: null });
}
/**
*
* Configuration
*
*/
/**
* Gets the current agentic configuration.
* Automatically disables agentic mode if no MCP servers are configured.
* @param settings - Global settings configuration
* @param perChatOverrides - Optional per-chat MCP server overrides
*/
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
const maxToolPreviewLines =
Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines;
return {
enabled: mcpStore.hasEnabledServers(perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
maxTurns,
maxToolPreviewLines
};
}
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
const {
onChunk,
onReasoningChunk,
onToolCallChunk,
onAttachments,
onModel,
onComplete,
onError,
onTimings
} = callbacks;
const agenticConfig = this.getConfig(config(), perChatOverrides);
if (!agenticConfig.enabled) return { handled: false };
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
return { handled: false };
}
const tools = mcpStore.getToolDefinitionsForLLM();
if (tools.length === 0) {
console.log('[AgenticStore] No tools available, falling back to standard chat');
return { handled: false };
}
console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`);
const normalizedMessages: ApiChatMessageData[] = messages
.map((msg) => {
if ('id' in msg && 'convId' in msg && 'timestamp' in msg)
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.updateSession(conversationId, {
isRunning: true,
currentTurn: 0,
totalToolCalls: 0,
lastError: null
});
mcpStore.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.updateSession(conversationId, { lastError: normalizedError });
onError?.(normalizedError);
return { handled: true, error: normalizedError };
} finally {
this.updateSession(conversationId, { isRunning: false });
await mcpStore
.releaseConnection()
.catch((err: unknown) =>
console.warn('[AgenticStore] Failed to release MCP connection:', err)
);
}
}
private async executeAgenticLoop(params: {
conversationId: string;
messages: ApiChatMessageData[];
options: AgenticFlowOptions;
tools: ReturnType<typeof mcpStore.getToolDefinitionsForLLM>;
agenticConfig: AgenticConfig;
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
}): Promise<void> {
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.updateSession(conversationId, { currentTurn: 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;
const emittedToolCallStates = new SvelteMap<
number,
{ emittedOnce: boolean; lastArgs: string }
>();
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[];
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) {
const output = `\n\n<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:${toolName}>>>\n<<<TOOL_ARGS_START>>>\n${toolArgs}`;
onChunk?.(output);
state.emittedOnce = true;
state.lastArgs = toolArgs;
emittedToolCallStates.set(i, state);
} else if (toolArgs.length > state.lastArgs.length) {
onChunk?.(toolArgs.slice(state.lastArgs.length));
state.lastArgs = toolArgs;
emittedToolCallStates.set(i, state);
}
}
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
const name = turnToolCalls[0].function.name || '';
const args = turnToolCalls[0].function.arguments || '';
const argsLengthBucket = Math.floor(args.length / 100);
if (
name !== lastStreamingToolCallName ||
argsLengthBucket !== lastStreamingToolCallArgsLength
) {
lastStreamingToolCallName = name;
lastStreamingToolCallArgsLength = argsLengthBucket;
this.updateSession(conversationId, {
streamingToolCall: { 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.updateSession(conversationId, { streamingToolCall: null });
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.updateSession(conversationId, { totalToolCalls: 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;
}
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 mcpStore.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);
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
});
}
if (turnStats.toolCalls.length > 0) agenticTimings.perTurn!.push(turnStats);
}
onChunk?.('\n\n```\nTurn limit reached\n```\n');
onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
}
private buildFinalTimings(
capturedTimings: ChatMessageTimings | undefined,
agenticTimings: ChatMessageAgenticTimings
): ChatMessageTimings | undefined {
if (agenticTimings.toolCallsCount === 0) return capturedTimings;
return {
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
};
}
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 ?? '' }
}));
}
private emitToolCallResult(
result: string,
maxLines: number,
emit?: (chunk: string) => void
): void {
if (!emit) return;
let output = `\n<<<TOOL_ARGS_END>>>`;
const lines = result.split('\n');
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
output += `\n${trimmedLines.join('\n')}\n<<<AGENTIC_TOOL_CALL_END>>>\n`;
emit(output);
}
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<string, string> = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp'
};
const extension = extensionMap[mimeType] ?? 'img';
return `mcp-attachment-${Date.now()}-${index}.${extension}`;
}
}
export const agenticStore = new AgenticStore();
// Auto-initialize in browser
if (browser) {
agenticStore.init();
}
/**
* Helper functions for reactive access in components.
* These require conversationId parameter for per-conversation state.
*/
export function agenticIsRunning(conversationId: string) {
return agenticStore.isRunning(conversationId);
}
export function agenticCurrentTurn(conversationId: string) {
return agenticStore.currentTurn(conversationId);
}
export function agenticTotalToolCalls(conversationId: string) {
return agenticStore.totalToolCalls(conversationId);
}
export function agenticLastError(conversationId: string) {
return agenticStore.lastError(conversationId);
}
export function agenticStreamingToolCall(conversationId: string) {
return agenticStore.streamingToolCall(conversationId);
}
export function agenticIsAnyRunning() {
return agenticStore.isAnyRunning;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,42 @@
/**
* conversationsStore - Reactive State Store for Conversations
*
* This store contains ONLY reactive state ($state, $derived).
* All business logic is delegated to ConversationsClient.
* Manages conversation lifecycle, persistence, navigation, and MCP server overrides.
*
* **Architecture & Relationships:**
* - **ConversationsClient**: Business logic facade (CRUD, navigation, import/export)
* - **DatabaseService**: Stateless IndexedDB layer
* - **conversationsStore** (this): Reactive state for UI components
* - **conversationsStore** (this): Reactive state + business logic
* - **chatStore**: Chat-specific state (streaming, loading)
*
* **Responsibilities:**
* - Hold reactive state for UI binding
* - Provide getters for computed values
* - Expose setters for ConversationsClient to update state
* - Forward method calls to ConversationsClient
* **Key Responsibilities:**
* - Conversation CRUD (create, load, delete)
* - Message management and tree navigation
* - MCP server per-chat overrides
* - Import/Export functionality
* - Title management with confirmation
*
* @see ConversationsClient in clients/ for business logic
* @see DatabaseService in services/database.ts for IndexedDB operations
*/
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
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 { mcpStore } from '$lib/stores/mcp.svelte';
import type { McpServerOverride } from '$lib/types/database';
import { setActiveConversationId } from '$lib/stores/shared';
import { chatStore } from '$lib/stores/chat.svelte';
import { MessageRole } from '$lib/enums';
class ConversationsStore {
/**
*
*
* State
*
*
*/
/** List of all conversations */
conversations = $state<DatabaseConversation[]>([]);
@ -43,59 +55,63 @@ class ConversationsStore {
/** Callback for title update confirmation dialog */
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
/** Reference to the client (lazy loaded to avoid circular dependency) */
private _client: typeof import('$lib/clients/conversations.client').conversationsClient | null =
null;
private get client() {
return this._client;
}
/** Check if store is ready (client initialized) */
get isReady(): boolean {
return this._client !== null;
}
/**
* Callback for updating message content in chatStore.
* Registered by chatStore to enable cross-store updates without circular dependency.
*/
private messageUpdateCallback:
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
| null = null;
/**
* Initialize the store by wiring up to the client.
*
*
* Lifecycle
*
*
*/
/**
* Initialize the store by loading conversations from database.
* Must be called once after app startup.
*/
async init(): Promise<void> {
if (!browser) return;
if (this._client) return;
if (this.isInitialized) return;
const { conversationsClient } = await import('$lib/clients/conversations.client');
this._client = conversationsClient;
// Register message update callback with chatStore to avoid circular dependency
chatStore.registerMessageUpdateCallback((messageId, updates) => {
const idx = this.findMessageIndex(messageId);
if (idx !== -1) {
this.updateMessageAtIndex(idx, updates);
}
});
conversationsClient.setStoreCallbacks({
getConversations: () => this.conversations,
setConversations: (conversations) => (this.conversations = conversations),
getActiveConversation: () => this.activeConversation,
setActiveConversation: (conversation) => {
this.activeConversation = conversation;
// Update shared state for chatStore to use without circular dependency
setActiveConversationId(conversation?.id ?? null);
},
getActiveMessages: () => this.activeMessages,
setActiveMessages: (messages) => (this.activeMessages = messages),
updateActiveMessages: (updater) => (this.activeMessages = updater(this.activeMessages)),
setInitialized: (initialized) => (this.isInitialized = initialized),
getPendingMcpServerOverrides: () => this.pendingMcpServerOverrides,
setPendingMcpServerOverrides: (overrides) => (this.pendingMcpServerOverrides = overrides),
getTitleUpdateConfirmationCallback: () => this.titleUpdateConfirmationCallback
});
await conversationsClient.initialize();
try {
await this.loadConversations();
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize conversations:', error);
}
}
/**
* Alias for init() for backward compatibility.
*/
async initialize(): Promise<void> {
return this.init();
}
/**
* Register a callback for message updates from other stores.
* Called by chatStore during initialization.
*/
registerMessageUpdateCallback(
callback: (messageId: string, updates: Partial<DatabaseMessage>) => void
): void {
this.messageUpdateCallback = callback;
}
/**
*
*
* Message Array Operations
*
*
*/
/**
* Adds a message to the active messages array
*/
@ -145,84 +161,364 @@ class ConversationsStore {
this.titleUpdateConfirmationCallback = callback;
}
async initialize(): Promise<void> {
if (!this.client) return;
return this.client.initialize();
}
/**
*
*
* Conversation CRUD
*
*
*/
/**
* Loads all conversations from the database
*/
async loadConversations(): Promise<void> {
if (!this.client) return;
return this.client.loadConversations();
const conversations = await DatabaseService.getAllConversations();
this.conversations = 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<string> {
if (!this.client) throw new Error('ConversationsStore not initialized');
return this.client.createConversation(name);
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
const conversation = await DatabaseService.createConversation(conversationName);
if (this.pendingMcpServerOverrides.length > 0) {
// Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({
serverId: o.serverId,
enabled: o.enabled
}));
conversation.mcpServerOverrides = plainOverrides;
await DatabaseService.updateConversation(conversation.id, {
mcpServerOverrides: plainOverrides
});
this.pendingMcpServerOverrides = [];
}
this.conversations = [conversation, ...this.conversations];
this.activeConversation = conversation;
this.activeMessages = [];
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<boolean> {
if (!this.client) return false;
return this.client.loadConversation(convId);
try {
const conversation = await DatabaseService.getConversation(convId);
if (!conversation) {
return false;
}
this.pendingMcpServerOverrides = [];
this.activeConversation = conversation;
if (conversation.currNode) {
const allMessages = await DatabaseService.getConversationMessages(convId);
const filteredMessages = filterByLeafNodeId(
allMessages,
conversation.currNode,
false
) as DatabaseMessage[];
this.activeMessages = filteredMessages;
} else {
const messages = await DatabaseService.getConversationMessages(convId);
this.activeMessages = 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.
*/
private runMcpHealthChecksForConversation(mcpServerOverrides?: McpServerOverride[]): void {
if (!mcpServerOverrides?.length) {
return;
}
const enabledServers = mcpStore.getEnabledServersForConversation(mcpServerOverrides);
if (enabledServers.length === 0) {
return;
}
console.log(
`[conversationsStore] Running health checks for ${enabledServers.length} MCP server(s)`
);
// Run health checks in background (don't await)
mcpStore.runHealthChecksForServers(enabledServers).catch((error) => {
console.warn('[conversationsStore] MCP health checks failed:', error);
});
}
/**
* Clears the active conversation and messages.
*/
clearActiveConversation(): void {
if (!this.client) return;
this.client.clearActiveConversation();
this.activeConversation = null;
this.activeMessages = [];
}
/**
* Deletes a conversation and all its messages
* @param convId - The conversation ID to delete
*/
async deleteConversation(convId: string): Promise<void> {
if (!this.client) return;
return this.client.deleteConversation(convId);
try {
await DatabaseService.deleteConversation(convId);
this.conversations = this.conversations.filter((c) => c.id !== convId);
if (this.activeConversation?.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<void> {
if (!this.client) return;
return this.client.deleteAll();
try {
const allConversations = await DatabaseService.getAllConversations();
for (const conv of allConversations) {
await DatabaseService.deleteConversation(conv.id);
}
this.clearActiveConversation();
this.conversations = [];
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<void> {
if (!this.client) return;
return this.client.refreshActiveMessages();
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
if (allMessages.length === 0) {
this.activeMessages = [];
return;
}
const leafNodeId =
this.activeConversation.currNode ||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
this.activeMessages = currentPath;
}
/**
* Gets all messages for a specific conversation
* @param convId - The conversation ID
* @returns Array of messages
*/
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
if (!this.client) return [];
return this.client.getConversationMessages(convId);
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<void> {
if (!this.client) return;
return this.client.updateConversationName(convId, name);
try {
await DatabaseService.updateConversation(convId, { name });
const convIndex = this.conversations.findIndex((c) => c.id === convId);
if (convIndex !== -1) {
this.conversations[convIndex].name = name;
this.conversations = [...this.conversations];
}
if (this.activeConversation?.id === convId) {
this.activeConversation = { ...this.activeConversation, 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<boolean> {
if (!this.client) return false;
return this.client.updateConversationTitleWithConfirmation(convId, newTitle);
}
try {
const currentConfig = config();
updateConversationTimestamp(): void {
if (!this.client) return;
this.client.updateConversationTimestamp();
}
if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) {
const conversation = await DatabaseService.getConversation(convId);
if (!conversation) return false;
async updateCurrentNode(nodeId: string): Promise<void> {
if (!this.client) return;
return this.client.updateCurrentNode(nodeId);
}
const shouldUpdate = await this.titleUpdateConfirmationCallback(
conversation.name,
newTitle
);
if (!shouldUpdate) return false;
}
async navigateToSibling(siblingId: string): Promise<void> {
if (!this.client) return;
return this.client.navigateToSibling(siblingId);
}
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
if (!this.client) {
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
await this.updateConversationName(convId, newTitle);
return true;
} catch (error) {
console.error('Failed to update conversation title with confirmation:', error);
return false;
}
return this.client.getMcpServerOverride(serverId);
}
/**
* Updates conversation lastModified timestamp and moves it to top of list
*/
updateConversationTimestamp(): void {
if (!this.activeConversation) return;
const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (chatIndex !== -1) {
this.conversations[chatIndex].lastModified = Date.now();
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
this.conversations = [updatedConv, ...this.conversations];
}
}
/**
* Updates the current node of the active conversation
* @param nodeId - The new current node ID
*/
async updateCurrentNode(nodeId: string): Promise<void> {
if (!this.activeConversation) return;
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
this.activeConversation = { ...this.activeConversation, 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<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const currentFirstUserMessage = this.activeMessages.find(
(m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
);
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
await this.refreshActiveMessages();
if (rootMessage && this.activeMessages.length > 0) {
const newFirstUserMessage = this.activeMessages.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(
this.activeConversation.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 {
if (this.activeConversation) {
return this.activeConversation.mcpServerOverrides?.find(
(o: McpServerOverride) => o.serverId === serverId
);
}
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
}
/**
@ -233,61 +529,279 @@ class ConversationsStore {
if (this.activeConversation?.mcpServerOverrides) {
return this.activeConversation.mcpServerOverrides;
}
return this.pendingMcpServerOverrides;
}
/**
* 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 {
if (!this.client) {
const override = this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
return override?.enabled ?? false;
}
return this.client.isMcpServerEnabledForChat(serverId);
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<void> {
if (!this.client) return;
return this.client.setMcpServerOverride(serverId, enabled);
}
async toggleMcpServerForChat(serverId: string): Promise<void> {
if (!this.client) return;
return this.client.toggleMcpServerForChat(serverId);
}
async removeMcpServerOverride(serverId: string): Promise<void> {
if (!this.client) return;
return this.client.removeMcpServerOverride(serverId);
}
clearPendingMcpServerOverrides(): void {
if (!this.client) {
this.pendingMcpServerOverrides = [];
if (!this.activeConversation) {
this.setPendingMcpServerOverride(serverId, enabled);
return;
}
this.client.clearPendingMcpServerOverrides();
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
const currentOverrides = (this.activeConversation.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(this.activeConversation.id, {
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
});
this.activeConversation = {
...this.activeConversation,
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
};
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (convIndex !== -1) {
this.conversations[convIndex].mcpServerOverrides =
newOverrides.length > 0 ? newOverrides : undefined;
this.conversations = [...this.conversations];
}
}
/**
* Sets or removes a pending MCP server override (for new conversations).
*/
private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
if (enabled === undefined) {
this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter(
(o) => o.serverId !== serverId
);
} else {
const existingIndex = this.pendingMcpServerOverrides.findIndex(
(o) => o.serverId === serverId
);
if (existingIndex >= 0) {
const newOverrides = [...this.pendingMcpServerOverrides];
newOverrides[existingIndex] = { serverId, enabled };
this.pendingMcpServerOverrides = newOverrides;
} else {
this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }];
}
}
}
/**
* Toggles MCP server enabled state for the active conversation.
* @param serverId - The server ID to toggle
*/
async toggleMcpServerForChat(serverId: string): Promise<void> {
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<void> {
await this.setMcpServerOverride(serverId, undefined);
}
/**
* Clears all pending MCP server overrides.
*/
clearPendingMcpServerOverrides(): void {
this.pendingMcpServerOverrides = [];
}
/**
*
*
* Import & Export
*
*
*/
/**
* Downloads a conversation as JSON file.
* @param convId - The conversation ID to download
*/
async downloadConversation(convId: string): Promise<void> {
if (!this.client) return;
return this.client.downloadConversation(convId);
let conversation: DatabaseConversation | null;
let messages: DatabaseMessage[];
if (this.activeConversation?.id === convId) {
conversation = this.activeConversation;
messages = this.activeMessages;
} 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<DatabaseConversation[]> {
if (!this.client) return [];
return this.client.exportAllConversations();
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<DatabaseConversation[]> {
if (!this.client) return [];
return this.client.importConversations();
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 }> {
if (!this.client) return { imported: 0, skipped: 0 };
return this.client.importConversationsData(data);
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);
}
}

View File

@ -1,36 +1,182 @@
/**
* mcpStore - Reactive State Store for MCP (Model Context Protocol)
* mcpStore - Reactive State Store for MCP Operations
*
* This store contains ONLY reactive state ($state, $derived).
* All business logic is delegated to MCPClient.
* Implements the "Host" role in MCP architecture, coordinating multiple server
* connections and providing a unified interface for tool operations.
*
* **Architecture & Relationships:**
* - **MCPClient**: Business logic facade (lifecycle, tool execution, health checks)
* - **MCPService**: Stateless protocol layer (transport, connect, callTool)
* - **mcpStore** (this): Reactive state for UI components
* - **mcpStore** (this): Reactive state + business logic
*
* **Responsibilities:**
* - Hold reactive state for UI binding
* - Provide getters for computed values
* - Expose setters for MCPClient to update state
* - Forward method calls to MCPClient
* **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
*
* @see MCPClient in clients/mcp/ for business logic
* @see MCPService in services/mcp.ts for protocol operations
* @see MCPService in services/mcp.service.ts for protocol operations
*/
import { mcpClient, buildMcpClientConfig } from '$lib/clients/mcp.client';
import { browser } from '$app/environment';
import { MCPService } from '$lib/services/mcp.service';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { parseMcpServerSettings, detectMcpTransportFromUrl } from '$lib/utils';
import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums';
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
import type {
MCPToolCall,
OpenAIToolDefinition,
ServerStatus,
ToolExecutionResult,
MCPClientConfig,
MCPConnection,
HealthCheckParams,
ServerCapabilities,
ClientCapabilities,
MCPCapabilitiesInfo,
MCPConnectionLog,
MCPPromptInfo,
GetPromptResult,
Tool,
Prompt,
HealthCheckState,
MCPServerSettingsEntry,
MCPPromptInfo,
GetPromptResult
MCPServerConfig
} from '$lib/types';
import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
import type { McpServerOverride } from '$lib/types/database';
import { parseMcpServerSettings } from '$lib/utils';
import { HealthCheckStatus } from '$lib/enums';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import type { SettingsConfigType } from '$lib/types/settings';
function generateMcpServerId(id: unknown, index: number): string {
if (typeof id === 'string' && id.trim()) return id.trim();
return `${MCP_SERVER_ID_PREFIX}${index + 1}`;
}
function parseServerSettings(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:', 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,
name: (entry as { name?: string })?.name,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
headers: headers || undefined
} satisfies MCPServerSettingsEntry;
});
}
function buildServerConfig(
entry: MCPServerSettingsEntry,
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
): MCPServerConfig | undefined {
if (!entry?.url) return undefined;
let headers: Record<string, string> | undefined;
if (entry.headers) {
try {
const parsed = JSON.parse(entry.headers);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
headers = parsed as Record<string, string>;
} catch {
console.warn('[MCP] Failed to parse custom headers JSON:', entry.headers);
}
}
return {
url: entry.url,
transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
headers
};
}
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;
}
function buildMcpClientConfigInternal(
cfg: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseServerSettings(cfg.mcpServers);
if (!rawServers.length) return undefined;
const servers: Record<string, MCPServerConfig> = {};
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
};
}
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 function buildMcpClientConfig(
cfg: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
return buildMcpClientConfigInternal(cfg, perChatOverrides);
}
class MCPStore {
private _isInitializing = $state(false);
@ -39,10 +185,41 @@ class MCPStore {
private _connectedServers = $state<string[]>([]);
private _healthChecks = $state<Record<string, HealthCheckState>>({});
/**
* Update state from MCPClient
*/
updateState(state: {
private connections = new Map<string, MCPConnection>();
private toolsIndex = new Map<string, string>();
private configSignature: string | null = null;
private initPromise: Promise<boolean> | null = null;
private activeFlowCount = 0;
get isInitializing(): boolean {
return this._isInitializing;
}
get isInitialized(): boolean {
return this.connections.size > 0;
}
get error(): string | null {
return this._error;
}
get toolCount(): number {
return this._toolCount;
}
get connectedServerCount(): number {
return this._connectedServers.length;
}
get connectedServerNames(): string[] {
return this._connectedServers;
}
get isEnabled(): boolean {
const mcpConfig = buildMcpClientConfigInternal(config());
return (
mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
);
}
get availableTools(): string[] {
return Array.from(this.toolsIndex.keys());
}
private updateState(state: {
isInitializing?: boolean;
error?: string | null;
toolCount?: number;
@ -54,125 +231,42 @@ class MCPStore {
if (state.connectedServers !== undefined) this._connectedServers = state.connectedServers;
}
/**
* Update health check state from MCPClient.
*/
updateHealthCheck(serverId: string, state: HealthCheckState): void {
this._healthChecks = { ...this._healthChecks, [serverId]: state };
}
get isInitializing(): boolean {
return this._isInitializing;
}
get isInitialized(): boolean {
return mcpClient.isInitialized;
}
get error(): string | null {
return this._error;
}
get toolCount(): number {
return this._toolCount;
}
get connectedServerCount(): number {
return this._connectedServers.length;
}
get connectedServerNames(): string[] {
return this._connectedServers;
}
/**
* Check if MCP is enabled (has configured servers)
*/
get isEnabled(): boolean {
const mcpConfig = buildMcpClientConfig(config());
return (
mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
);
}
/**
* Get list of available tool names
*/
get availableTools(): string[] {
return mcpClient.getToolNames();
}
/**
* Get health check state for a specific server
*/
getHealthCheckState(serverId: string): HealthCheckState {
return this._healthChecks[serverId] ?? { status: 'idle' };
}
/**
* Check if health check has been performed for a server
*/
hasHealthCheck(serverId: string): boolean {
return serverId in this._healthChecks && this._healthChecks[serverId].status !== 'idle';
}
/**
* Clear health check state for a specific server.
*/
clearHealthCheck(serverId: string): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [serverId]: _removed, ...rest } = this._healthChecks;
this._healthChecks = rest;
}
/**
* Clear all health check states.
*/
clearAllHealthChecks(): void {
this._healthChecks = {};
}
/**
* Clear error state
*/
clearError(): void {
this._error = null;
}
/**
*
* Server Management (CRUD)
*
*/
/**
* Get all configured MCP servers from settings (unsorted).
*/
getServers(): MCPServerSettingsEntry[] {
return parseMcpServerSettings(config().mcpServers);
}
/**
* Gets a display label for an MCP server.
* Automatically fetches health state from store.
*/
getServerLabel(server: MCPServerSettingsEntry): string {
const healthState = this.getHealthCheckState(server.id);
if (healthState?.status === HealthCheckStatus.SUCCESS) {
if (healthState?.status === HealthCheckStatus.SUCCESS)
return (
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
);
}
return server.url;
}
/**
* Check if any server is still loading (idle or connecting).
*/
isAnyServerLoading(): boolean {
const servers = this.getServers();
return servers.some((s) => {
return this.getServers().some((s) => {
const state = this.getHealthCheckState(s.id);
return (
state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING
@ -180,29 +274,14 @@ class MCPStore {
});
}
/**
* Get servers sorted alphabetically by display label.
* Returns unsorted list while health checks are in progress to prevent UI jumping.
*/
getServersSorted(): MCPServerSettingsEntry[] {
const servers = this.getServers();
// Don't sort while any server is still loading - prevents UI jumping
if (this.isAnyServerLoading()) {
return servers;
}
// Sort alphabetically by display label once all health checks are done
return [...servers].sort((a, b) => {
const labelA = this.getServerLabel(a);
const labelB = this.getServerLabel(b);
return labelA.localeCompare(labelB);
});
if (this.isAnyServerLoading()) return servers;
return [...servers].sort((a, b) =>
this.getServerLabel(a).localeCompare(this.getServerLabel(b))
);
}
/**
* Add a new MCP server
*/
addServer(
serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
): void {
@ -218,93 +297,510 @@ class MCPStore {
settingsStore.updateConfig('mcpServers', JSON.stringify([...servers, newServer]));
}
/**
* Update an existing MCP server
*/
updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
const servers = this.getServers();
const nextServers = servers.map((server) =>
server.id === id ? { ...server, ...updates } : server
settingsStore.updateConfig(
'mcpServers',
JSON.stringify(
servers.map((server) => (server.id === id ? { ...server, ...updates } : server))
)
);
settingsStore.updateConfig('mcpServers', JSON.stringify(nextServers));
}
/**
* Remove an MCP server by ID
*/
removeServer(id: string): void {
const servers = this.getServers();
settingsStore.updateConfig('mcpServers', JSON.stringify(servers.filter((s) => s.id !== id)));
this.clearHealthCheck(id);
}
/**
* Check if there are any available MCP servers (enabled in settings).
* Used to determine if McpServerSelector should be shown.
*/
hasAvailableServers(): boolean {
const servers = parseMcpServerSettings(config().mcpServers);
return servers.some((s) => s.enabled && s.url.trim());
return parseMcpServerSettings(config().mcpServers).some((s) => s.enabled && s.url.trim());
}
/**
* Check if there are any MCP servers enabled for the current chat.
*/
hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
return Boolean(buildMcpClientConfig(config(), perChatOverrides));
return Boolean(buildMcpClientConfigInternal(config(), perChatOverrides));
}
/**
* Gets enabled MCP servers for a conversation based on per-chat overrides.
* Returns servers that are both globally enabled AND enabled for this chat.
*/
getEnabledServersForConversation(
perChatOverrides?: McpServerOverride[]
): MCPServerSettingsEntry[] {
if (!perChatOverrides?.length) {
return [];
}
const allServers = this.getServers();
return allServers.filter((server) => {
if (!perChatOverrides?.length) return [];
return this.getServers().filter((server) => {
if (!server.enabled) return false;
const override = perChatOverrides.find((o) => o.serverId === server.id);
return override?.enabled ?? false;
});
}
/**
*
* Prompts
*
*/
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
if (!browser) return false;
const mcpConfig = buildMcpClientConfigInternal(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!);
}
private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise<boolean> {
console.log('[MCPStore] Starting initialization...');
this.updateState({ isInitializing: true, error: null });
this.configSignature = signature;
const serverEntries = Object.entries(mcpConfig.servers);
if (serverEntries.length === 0) {
console.log('[MCPStore] No servers configured');
this.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<boolean> {
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('[MCPStore] 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(
`[MCPStore] 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(`[MCPStore] Failed to connect:`, result.reason);
}
}
const successCount = this.connections.size;
if (successCount === 0 && serverEntries.length > 0) {
this.updateState({
isInitializing: false,
error: 'All MCP server connections failed',
toolCount: 0,
connectedServers: []
});
this.initPromise = null;
return false;
}
this.updateState({
isInitializing: false,
error: null,
toolCount: this.toolsIndex.size,
connectedServers: Array.from(this.connections.keys())
});
console.log(
`[MCPStore] Initialization complete: ${successCount}/${serverEntries.length} servers, ${this.toolsIndex.size} tools`
);
this.initPromise = null;
return true;
}
private createListChangedHandlers(serverName: string): ListChangedHandlers {
return {
tools: {
onChanged: (error: Error | null, tools: Tool[] | null) => {
if (error) {
console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error);
return;
}
console.log(`[MCPStore][${serverName}] Tools list changed, ${tools?.length ?? 0} tools`);
this.handleToolsListChanged(serverName, tools ?? []);
}
},
prompts: {
onChanged: (error: Error | null, prompts: Prompt[] | null) => {
if (error) {
console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error);
return;
}
console.log(
`[MCPStore][${serverName}] Prompts list changed, ${prompts?.length ?? 0} prompts`
);
}
}
};
}
private handleToolsListChanged(serverName: string, tools: Tool[]): void {
const connection = this.connections.get(serverName);
if (!connection) return;
for (const [toolName, ownerServer] of this.toolsIndex.entries()) {
if (ownerServer === serverName) this.toolsIndex.delete(toolName);
}
connection.tools = tools;
for (const tool of tools) {
if (this.toolsIndex.has(tool.name))
console.warn(
`[MCPStore] 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);
}
this.updateState({ toolCount: this.toolsIndex.size });
}
acquireConnection(): void {
this.activeFlowCount++;
console.log(`[MCPStore] Connection acquired (active flows: ${this.activeFlowCount})`);
}
async releaseConnection(shutdownIfUnused = true): Promise<void> {
this.activeFlowCount = Math.max(0, this.activeFlowCount - 1);
console.log(`[MCPStore] Connection released (active flows: ${this.activeFlowCount})`);
if (shutdownIfUnused && this.activeFlowCount === 0) {
console.log('[MCPStore] No active flows, initiating lazy disconnect...');
await this.shutdown();
}
}
getActiveFlowCount(): number {
return this.activeFlowCount;
}
async shutdown(): Promise<void> {
if (this.initPromise) {
await this.initPromise.catch(() => {});
this.initPromise = null;
}
if (this.connections.size === 0) return;
console.log(`[MCPStore] Shutting down ${this.connections.size} connections...`);
await Promise.all(
Array.from(this.connections.values()).map((conn) =>
MCPService.disconnect(conn).catch((error) =>
console.warn(`[MCPStore] Error disconnecting ${conn.serverName}:`, error)
)
)
);
this.connections.clear();
this.toolsIndex.clear();
this.configSignature = null;
this.updateState({ isInitializing: false, error: null, toolCount: 0, connectedServers: [] });
console.log('[MCPStore] Shutdown complete');
}
getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [];
for (const connection of this.connections.values()) {
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
type: 'object',
properties: {},
required: []
};
tools.push({
type: 'function' as const,
function: {
name: tool.name,
description: tool.description,
parameters: this.normalizeSchemaProperties(rawSchema)
}
});
}
}
return tools;
}
private normalizeSchemaProperties(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema;
const normalized = { ...schema };
if (normalized.properties && typeof normalized.properties === 'object') {
const props = normalized.properties as Record<string, Record<string, unknown>>;
const normalizedProps: Record<string, Record<string, unknown>> = {};
for (const [key, prop] of Object.entries(props)) {
if (!prop || typeof prop !== 'object') {
normalizedProps[key] = prop;
continue;
}
const normalizedProp = { ...prop };
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<string, unknown>)
);
if (normalizedProp.items && typeof normalizedProp.items === 'object')
normalizedProp.items = this.normalizeSchemaProperties(
normalizedProp.items as Record<string, unknown>
);
normalizedProps[key] = normalizedProp;
}
normalized.properties = normalizedProps;
}
return normalized;
}
getToolNames(): string[] {
return Array.from(this.toolsIndex.keys());
}
hasTool(toolName: string): boolean {
return this.toolsIndex.has(toolName);
}
getToolServer(toolName: string): string | undefined {
return this.toolsIndex.get(toolName);
}
/**
* Check if any connected server supports prompts
*/
hasPromptsSupport(): boolean {
return mcpClient.hasPromptsSupport();
for (const connection of this.connections.values()) {
if (connection.serverCapabilities?.prompts) return true;
}
return false;
}
/**
* Get all prompts from all connected servers
*/
async getAllPrompts(): Promise<MCPPromptInfo[]> {
return mcpClient.getAllPrompts();
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 specific prompt by name from a server.
* Throws an error if the server is not found or prompt execution fails.
*/
async getPrompt(
serverName: string,
promptName: string,
args?: Record<string, string>
): Promise<GetPromptResult> {
return mcpClient.getPrompt(serverName, promptName, args);
const connection = this.connections.get(serverName);
if (!connection) throw new Error(`Server "${serverName}" not found for prompt "${promptName}"`);
return MCPService.getPrompt(connection, promptName, args);
}
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
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);
}
async executeToolByName(
toolName: string,
args: Record<string, unknown>,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
} 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}`);
}
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(`[MCPStore] 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 }
);
}
private parseHeaders(headersJson?: string): Record<string, string> | 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<string, string>;
} catch {
console.warn('[MCPStore] Failed to parse custom headers JSON:', headersJson);
}
return undefined;
}
async runHealthChecksForServers(
servers: {
id: string;
enabled: boolean;
url: string;
requestTimeoutSeconds: number;
headers?: string;
}[],
skipIfChecked = true
): Promise<void> {
const serversToCheck = skipIfChecked
? servers.filter((s) => !this.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)));
}
}
async runHealthCheck(server: HealthCheckParams): Promise<void> {
const trimmedUrl = server.url.trim();
const logs: MCPConnectionLog[] = [];
let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE;
if (!trimmedUrl) {
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.ERROR,
message: 'Please enter a server URL first.',
logs: []
});
return;
}
this.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, log) => {
currentPhase = phase;
logs.push(log);
this.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
);
this.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
});
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.ERROR,
message,
phase: currentPhase,
logs
});
}
}
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;
}
}