refactor: Cleanup

This commit is contained in:
Aleksander Grygier 2026-01-12 09:32:32 +01:00
parent 74b119e81e
commit 144148125b
44 changed files with 5225 additions and 4477 deletions

View File

@ -0,0 +1,622 @@
/**
* AgenticClient - Business Logic Facade for Agentic Loop Orchestration
*
* Coordinates the multi-turn agentic loop with MCP tools:
* - LLM streaming with tool call detection
* - Tool execution via MCPClient
* - Session state management
* - Turn limit enforcement
*
* **Architecture & Relationships:**
* - **AgenticClient** (this class): Orchestrates multi-turn tool loop
* - Uses MCPClient for tool execution
* - Uses ChatService for LLM streaming
* - Updates agenticStore with reactive state
*
* - **MCPClient**: Tool execution facade
* - **agenticStore**: Reactive state only ($state)
*
* **Key Features:**
* - Multi-turn tool call orchestration
* - Automatic routing of tool calls to appropriate MCP servers
* - Raw LLM output streaming (UI formatting is separate concern)
* - Lazy disconnect after flow completes
*/
import { mcpClient } from '$lib/clients';
import { ChatService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { getAgenticConfig } from '$lib/utils/agentic';
import { toAgenticMessages } from '$lib/utils';
import type { AgenticMessage, AgenticToolCallList } from '$lib/types/agentic';
import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api';
import type {
ChatMessagePromptProgress,
ChatMessageTimings,
ChatMessageAgenticTimings,
ChatMessageToolCallTiming,
ChatMessageAgenticTurnStats
} from '$lib/types/chat';
import type { MCPToolCall } from '$lib/types/mcp';
import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '$lib/types/database';
export interface AgenticFlowCallbacks {
onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (serializedToolCalls: string) => void;
onModel?: (model: string) => void;
onComplete?: (
content: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCalls?: string
) => void;
onError?: (error: Error) => void;
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
}
export interface AgenticFlowOptions {
stream?: boolean;
model?: string;
temperature?: number;
max_tokens?: number;
[key: string]: unknown;
}
export interface AgenticFlowParams {
messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[];
options?: AgenticFlowOptions;
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
/** Per-chat MCP server overrides */
perChatOverrides?: McpServerOverride[];
}
export interface AgenticFlowResult {
handled: boolean;
error?: Error;
}
interface AgenticStoreStateCallbacks {
setRunning: (running: boolean) => void;
setCurrentTurn: (turn: number) => void;
setTotalToolCalls: (count: number) => void;
setLastError: (error: Error | null) => void;
setStreamingToolCall: (tc: { name: string; arguments: string } | null) => void;
clearStreamingToolCall: () => void;
}
export class AgenticClient {
private storeCallbacks: AgenticStoreStateCallbacks | null = null;
/**
*
*
* Store Integration
*
*
*/
/**
* Sets callbacks for store state updates.
* Called by agenticStore during initialization.
*/
setStoreCallbacks(callbacks: AgenticStoreStateCallbacks): void {
this.storeCallbacks = callbacks;
}
private get store(): AgenticStoreStateCallbacks {
if (!this.storeCallbacks) {
throw new Error('AgenticClient: Store callbacks not initialized');
}
return this.storeCallbacks;
}
/**
*
*
* Agentic Flow
*
*
*/
/**
* Runs the agentic orchestration loop with MCP tools.
* Main entry point called by ChatClient when agentic mode is enabled.
*
* Coordinates: initial LLM request tool call detection tool execution loop until done.
*
* @param params - Flow parameters including messages, options, callbacks, and signal
* @returns Result indicating if the flow handled the request
*/
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
const { messages, options = {}, callbacks, signal, perChatOverrides } = params;
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } =
callbacks;
// Get agentic configuration (considering per-chat MCP overrides)
const agenticConfig = getAgenticConfig(config(), perChatOverrides);
if (!agenticConfig.enabled) {
return { handled: false };
}
// Ensure MCP is initialized with per-chat overrides
const initialized = await mcpClient.ensureInitialized(perChatOverrides);
if (!initialized) {
console.log('[AgenticClient] MCP not initialized, falling back to standard chat');
return { handled: false };
}
const tools = mcpClient.getToolDefinitionsForLLM();
if (tools.length === 0) {
console.log('[AgenticClient] No tools available, falling back to standard chat');
return { handled: false };
}
console.log(`[AgenticClient] Starting agentic flow with ${tools.length} tools`);
const normalizedMessages: ApiChatMessageData[] = messages
.map((msg) => {
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
// DatabaseMessage - use ChatService to convert
return ChatService.convertDbMessageToApiChatMessageData(
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
);
}
return msg as ApiChatMessageData;
})
.filter((msg) => {
if (msg.role === 'system') {
const content = typeof msg.content === 'string' ? msg.content : '';
return content.trim().length > 0;
}
return true;
});
this.store.setRunning(true);
this.store.setCurrentTurn(0);
this.store.setTotalToolCalls(0);
this.store.setLastError(null);
try {
await this.executeAgenticLoop({
messages: normalizedMessages,
options,
tools,
agenticConfig,
callbacks: {
onChunk,
onReasoningChunk,
onToolCallChunk,
onModel,
onComplete,
onError,
onTimings
},
signal
});
return { handled: true };
} catch (error) {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this.store.setLastError(normalizedError);
onError?.(normalizedError);
return { handled: true, error: normalizedError };
} finally {
this.store.setRunning(false);
// Lazy Disconnect: Close MCP connections after agentic flow completes
// This prevents continuous keepalive/heartbeat polling when tools are not in use
await mcpClient.shutdown().catch((err) => {
console.warn('[AgenticClient] Failed to shutdown MCP after flow:', err);
});
console.log('[AgenticClient] MCP connections closed (lazy disconnect)');
}
}
private async executeAgenticLoop(params: {
messages: ApiChatMessageData[];
options: AgenticFlowOptions;
tools: ReturnType<typeof mcpClient.getToolDefinitionsForLLM>;
agenticConfig: ReturnType<typeof getAgenticConfig>;
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
}): Promise<void> {
const { messages, options, tools, agenticConfig, callbacks, signal } = params;
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onTimings } =
callbacks;
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
const allToolCalls: ApiChatCompletionToolCall[] = [];
let capturedTimings: ChatMessageTimings | undefined;
const agenticTimings: ChatMessageAgenticTimings = {
turns: 0,
toolCallsCount: 0,
toolsMs: 0,
toolCalls: [],
perTurn: [],
llm: {
predicted_n: 0,
predicted_ms: 0,
prompt_n: 0,
prompt_ms: 0
}
};
const maxTurns = agenticConfig.maxTurns;
const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
for (let turn = 0; turn < maxTurns; turn++) {
this.store.setCurrentTurn(turn + 1);
agenticTimings.turns = turn + 1;
if (signal?.aborted) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
// Filter reasoning content after first turn if configured
const shouldFilterReasoning = agenticConfig.filterReasoningAfterFirstTurn && turn > 0;
let turnContent = '';
let turnToolCalls: ApiChatCompletionToolCall[] = [];
let lastStreamingToolCallName = '';
let lastStreamingToolCallArgsLength = 0;
let turnTimings: ChatMessageTimings | undefined;
const turnStats: ChatMessageAgenticTurnStats = {
turn: turn + 1,
llm: {
predicted_n: 0,
predicted_ms: 0,
prompt_n: 0,
prompt_ms: 0
},
toolCalls: [],
toolsMs: 0
};
try {
await ChatService.sendMessage(
sessionMessages as ApiChatMessageData[],
{
...options,
stream: true,
tools: tools.length > 0 ? tools : undefined,
onChunk: (chunk: string) => {
turnContent += chunk;
onChunk?.(chunk);
},
onReasoningChunk: shouldFilterReasoning ? undefined : onReasoningChunk,
onToolCallChunk: (serialized: string) => {
try {
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
// Update store with streaming tool call state for UI visualization
// Only update when values actually change to avoid memory pressure
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
const name = turnToolCalls[0].function.name || '';
const args = turnToolCalls[0].function.arguments || '';
// Only update if name changed or args grew significantly (every 100 chars)
const argsLengthBucket = Math.floor(args.length / 100);
if (
name !== lastStreamingToolCallName ||
argsLengthBucket !== lastStreamingToolCallArgsLength
) {
lastStreamingToolCallName = name;
lastStreamingToolCallArgsLength = argsLengthBucket;
this.store.setStreamingToolCall({ name, arguments: args });
}
}
} catch {
// Ignore parse errors during streaming
}
},
onModel,
onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => {
onTimings?.(timings, progress);
if (timings) {
capturedTimings = timings;
turnTimings = timings;
}
},
onComplete: () => {
// Completion handled after sendMessage resolves
},
onError: (error: Error) => {
throw error;
}
},
undefined,
signal
);
this.store.clearStreamingToolCall();
if (turnTimings) {
agenticTimings.llm.predicted_n += turnTimings.predicted_n || 0;
agenticTimings.llm.predicted_ms += turnTimings.predicted_ms || 0;
agenticTimings.llm.prompt_n += turnTimings.prompt_n || 0;
agenticTimings.llm.prompt_ms += turnTimings.prompt_ms || 0;
turnStats.llm.predicted_n = turnTimings.predicted_n || 0;
turnStats.llm.predicted_ms = turnTimings.predicted_ms || 0;
turnStats.llm.prompt_n = turnTimings.prompt_n || 0;
turnStats.llm.prompt_ms = turnTimings.prompt_ms || 0;
}
} catch (error) {
if (signal?.aborted) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
onChunk?.(`\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`);
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
throw normalizedError;
}
if (turnToolCalls.length === 0) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
if (normalizedCalls.length === 0) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
for (const call of normalizedCalls) {
allToolCalls.push({
id: call.id,
type: call.type,
function: call.function ? { ...call.function } : undefined
});
}
this.store.setTotalToolCalls(allToolCalls.length);
onToolCallChunk?.(JSON.stringify(allToolCalls));
sessionMessages.push({
role: 'assistant',
content: turnContent || undefined,
tool_calls: normalizedCalls
});
for (const toolCall of normalizedCalls) {
if (signal?.aborted) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
// Start timing BEFORE emitToolCallStart to capture full perceived execution time
const toolStartTime = performance.now();
this.emitToolCallStart(toolCall, onChunk);
const mcpCall: MCPToolCall = {
id: toolCall.id,
function: {
name: toolCall.function.name,
arguments: toolCall.function.arguments
}
};
let result: string;
let toolSuccess = true;
try {
const executionResult = await mcpClient.executeTool(mcpCall, signal);
result = executionResult.content;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
toolSuccess = false;
}
const toolDurationMs = performance.now() - toolStartTime;
const toolTiming: ChatMessageToolCallTiming = {
name: toolCall.function.name,
duration_ms: Math.round(toolDurationMs),
success: toolSuccess
};
agenticTimings.toolCalls!.push(toolTiming);
agenticTimings.toolCallsCount++;
agenticTimings.toolsMs += Math.round(toolDurationMs);
turnStats.toolCalls.push(toolTiming);
turnStats.toolsMs += Math.round(toolDurationMs);
if (signal?.aborted) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
this.emitToolCallResult(result, maxToolPreviewLines, onChunk);
// Add tool result to session (sanitize base64 images for context)
const contextValue = this.isBase64Image(result) ? '[Image displayed to user]' : result;
sessionMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: contextValue
});
}
// Save per-turn stats (only if there were tool calls in this turn)
if (turnStats.toolCalls.length > 0) {
agenticTimings.perTurn!.push(turnStats);
}
}
onChunk?.('\n\n```\nTurn limit reached\n```\n');
onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
}
/**
*
*
* Timing & Statistics
*
*
*/
/**
* Builds final timings object with agentic stats.
* Single-turn flows return original timings; multi-turn includes aggregated stats.
*/
private buildFinalTimings(
capturedTimings: ChatMessageTimings | undefined,
agenticTimings: ChatMessageAgenticTimings
): ChatMessageTimings | undefined {
// If no tool calls were made, this was effectively a single-turn flow
// Return the original timings without agentic data
if (agenticTimings.toolCallsCount === 0) {
return capturedTimings;
}
const finalTimings: ChatMessageTimings = {
// Use the last turn's values as the "current" values for backward compatibility
predicted_n: capturedTimings?.predicted_n,
predicted_ms: capturedTimings?.predicted_ms,
prompt_n: capturedTimings?.prompt_n,
prompt_ms: capturedTimings?.prompt_ms,
cache_n: capturedTimings?.cache_n,
agentic: agenticTimings
};
return finalTimings;
}
/**
*
*
* Tool Call Processing
*
*
*/
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
if (!toolCalls) return [];
return toolCalls.map((call, index) => ({
id: call?.id ?? `tool_${index}`,
type: (call?.type as 'function') ?? 'function',
function: {
name: call?.function?.name ?? '',
arguments: call?.function?.arguments ?? ''
}
}));
}
/**
* Emit tool call start marker (shows "pending" state in UI).
*/
private emitToolCallStart(
toolCall: AgenticToolCallList[number],
emit?: (chunk: string) => void
): void {
if (!emit) return;
const toolName = toolCall.function.name;
const toolArgs = toolCall.function.arguments;
// Base64 encode args to avoid conflicts with markdown/HTML parsing
const toolArgsBase64 = btoa(unescape(encodeURIComponent(toolArgs)));
let output = `\n\n<<<AGENTIC_TOOL_CALL_START>>>`;
output += `\n<<<TOOL_NAME:${toolName}>>>`;
output += `\n<<<TOOL_ARGS_BASE64:${toolArgsBase64}>>>`;
emit(output);
}
/**
* Emit tool call result and end marker.
*/
private emitToolCallResult(
result: string,
maxLines: number,
emit?: (chunk: string) => void
): void {
if (!emit) return;
let output = '';
if (this.isBase64Image(result)) {
output += `\n![tool-result](${result.trim()})`;
} else {
// Don't wrap in code fences - result may already be markdown with its own code blocks
const lines = result.split('\n');
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
output += `\n${trimmedLines.join('\n')}`;
}
output += `\n<<<AGENTIC_TOOL_CALL_END>>>\n`;
emit(output);
}
/**
*
*
* Utilities
*
*
*/
private isBase64Image(content: string): boolean {
const trimmed = content.trim();
if (!trimmed.startsWith('data:image/')) return false;
const match = trimmed.match(/^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+=*)$/);
if (!match) return false;
const base64Payload = match[2];
return base64Payload.length > 0 && base64Payload.length % 4 === 0;
}
clearError(): void {
this.store.setLastError(null);
}
}
export const agenticClient = new AgenticClient();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,713 @@
/**
* ConversationsClient - Business Logic Facade for Conversation Operations
*
* Coordinates conversation lifecycle, persistence, and navigation.
*
* **Architecture & Relationships:**
* - **ConversationsClient** (this class): Business logic facade
* - Uses DatabaseService for IndexedDB operations
* - Updates conversationsStore with reactive state
* - Handles CRUD, import/export, branch navigation
*
* - **DatabaseService**: Stateless IndexedDB layer
* - **conversationsStore**: Reactive state only ($state)
*
* **Key Responsibilities:**
* - Conversation lifecycle (create, load, delete)
* - Message management and tree navigation
* - MCP server per-chat overrides
* - Import/Export functionality
* - Title management with confirmation
*/
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database.service';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
import type { McpServerOverride } from '$lib/types/database';
interface ConversationsStoreStateCallbacks {
getConversations: () => DatabaseConversation[];
setConversations: (conversations: DatabaseConversation[]) => void;
getActiveConversation: () => DatabaseConversation | null;
setActiveConversation: (conversation: DatabaseConversation | null) => void;
getActiveMessages: () => DatabaseMessage[];
setActiveMessages: (messages: DatabaseMessage[]) => void;
updateActiveMessages: (updater: (messages: DatabaseMessage[]) => DatabaseMessage[]) => void;
setInitialized: (initialized: boolean) => void;
getPendingMcpServerOverrides: () => McpServerOverride[];
setPendingMcpServerOverrides: (overrides: McpServerOverride[]) => void;
getTitleUpdateConfirmationCallback: () =>
| ((currentTitle: string, newTitle: string) => Promise<boolean>)
| undefined;
}
export class ConversationsClient {
private storeCallbacks: ConversationsStoreStateCallbacks | null = null;
/**
*
*
* Store Integration
*
*
*/
/**
* Sets callbacks for store state updates.
* Called by conversationsStore during initialization.
*/
setStoreCallbacks(callbacks: ConversationsStoreStateCallbacks): void {
this.storeCallbacks = callbacks;
}
private get store(): ConversationsStoreStateCallbacks {
if (!this.storeCallbacks) {
throw new Error('ConversationsClient: Store callbacks not initialized');
}
return this.storeCallbacks;
}
/**
*
*
* Lifecycle
*
*
*/
/**
* Initializes the conversations by loading from the database.
*/
async initialize(): Promise<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);
}
return true;
} catch (error) {
console.error('Failed to load conversation:', error);
return false;
}
}
/**
*
*
* Conversation CRUD
*
*
*/
/**
* Clears the active conversation and messages.
*/
clearActiveConversation(): void {
this.store.setActiveConversation(null);
this.store.setActiveMessages([]);
}
/**
* Deletes a conversation and all its messages
* @param convId - The conversation ID to delete
*/
async deleteConversation(convId: string): Promise<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 === 'user' && m.parent === rootMessage?.id
);
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(activeConv.id, currentLeafNodeId);
this.store.setActiveConversation({ ...activeConv, currNode: currentLeafNodeId });
await this.refreshActiveMessages();
const updatedActiveMessages = this.store.getActiveMessages();
if (rootMessage && updatedActiveMessages.length > 0) {
const newFirstUserMessage = updatedActiveMessages.find(
(m) => m.role === 'user' && m.parent === rootMessage.id
);
if (
newFirstUserMessage &&
newFirstUserMessage.content.trim() &&
(!currentFirstUserMessage ||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
) {
await this.updateConversationTitleWithConfirmation(
activeConv.id,
newFirstUserMessage.content.trim()
);
}
}
}
/**
*
*
* MCP Server Overrides
*
*
*/
/**
* Gets MCP server override for a specific server in the active conversation.
* Falls back to pending overrides if no active conversation exists.
* @param serverId - The server ID to check
* @returns The override if set, undefined if using global setting
*/
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
const activeConv = this.store.getActiveConversation();
if (activeConv) {
return activeConv.mcpServerOverrides?.find((o: McpServerOverride) => o.serverId === serverId);
}
return this.store.getPendingMcpServerOverrides().find((o) => o.serverId === serverId);
}
/**
* Checks if an MCP server is enabled for the active conversation.
* Per-chat override takes precedence over global setting.
* @param serverId - The server ID to check
* @param globalEnabled - The global enabled state from settings
* @returns True if server is enabled for this conversation
*/
isMcpServerEnabledForChat(serverId: string, globalEnabled: boolean): boolean {
const override = this.getMcpServerOverride(serverId);
return override !== undefined ? override.enabled : globalEnabled;
}
/**
* Sets or removes MCP server override for the active conversation.
* If no conversation exists, stores as pending override.
* @param serverId - The server ID to override
* @param enabled - The enabled state, or undefined to remove override
*/
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<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
* @param globalEnabled - The global enabled state from settings
*/
async toggleMcpServerForChat(serverId: string, globalEnabled: boolean): Promise<void> {
const currentEnabled = this.isMcpServerEnabledForChat(serverId, globalEnabled);
await this.setMcpServerOverride(serverId, !currentEnabled);
}
/**
* Resets MCP server to use global setting (removes per-chat override).
* @param serverId - The server ID to reset
*/
async resetMcpServerToGlobal(serverId: string): Promise<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

@ -0,0 +1,37 @@
/**
* Clients Module - Business Logic Facades
*
* This module exports all client classes which coordinate business logic:
* - MCPClient: MCP connection management and tool execution
* - ChatClient: Message operations, streaming, branching
* - AgenticClient: Multi-turn tool loop orchestration
* - ConversationsClient: Conversation CRUD and message management
*
* **Architecture:**
* - Clients coordinate between Services (stateless API) and Stores (reactive state)
* - Clients contain business logic, orchestration, and error handling
* - Stores only hold reactive state and delegate to Clients
*
* @see services/ for stateless API operations
* @see stores/ for reactive state
*/
// MCP Client
export { MCPClient, mcpClient } from './mcp.client';
export type { HealthCheckState, HealthCheckParams } from './mcp.client';
// Chat Client
export { ChatClient, chatClient } from './chat.client';
export type { ChatStreamCallbacks, ApiProcessingState, ErrorDialogState } from './chat.client';
// Agentic Client
export { AgenticClient, agenticClient } from './agentic.client';
export type {
AgenticFlowCallbacks,
AgenticFlowOptions,
AgenticFlowParams,
AgenticFlowResult
} from './agentic.client';
// Conversations Client
export { ConversationsClient, conversationsClient } from './conversations.client';

View File

@ -0,0 +1,615 @@
/**
* MCPClient - Business Logic Facade for MCP Operations
*
* Implements the "Host" role in MCP architecture, coordinating multiple server
* connections and providing a unified interface for tool operations.
*
* **Architecture & Relationships:**
* - **MCPClient** (this class): Business logic facade
* - Uses MCPService for low-level protocol operations
* - Updates mcpStore with reactive state
* - Coordinates multiple server connections
* - Aggregates tools from all connected servers
* - Routes tool calls to the appropriate server
*
* - **MCPService**: Stateless protocol layer (transport, connect, callTool)
* - **mcpStore**: Reactive state only ($state, getters, setters)
*
* **Key Responsibilities:**
* - Lifecycle management (initialize, shutdown)
* - Multi-server coordination
* - Tool name conflict detection and resolution
* - OpenAI-compatible tool definition generation
* - Automatic tool-to-server routing
* - Health checks
* - Usage statistics tracking
*/
import { browser } from '$app/environment';
import { MCPService, type MCPConnection } from '$lib/services/mcp.service';
import type {
MCPToolCall,
OpenAIToolDefinition,
ServerStatus,
ToolExecutionResult,
MCPClientConfig
} from '$lib/types/mcp';
import type { McpServerOverride } from '$lib/types/database';
import { MCPError } from '$lib/errors';
import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/utils/mcp';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { detectMcpTransportFromUrl } from '$lib/utils/mcp';
export type HealthCheckState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; tools: { name: string; description?: string }[] };
export interface HealthCheckParams {
id: string;
url: string;
requestTimeoutSeconds: number;
headers?: string;
}
export class MCPClient {
private connections = new Map<string, MCPConnection>();
private toolsIndex = new Map<string, string>();
private configSignature: string | null = null;
private initPromise: Promise<boolean> | null = null;
private onStateChange?: (state: {
isInitializing?: boolean;
error?: string | null;
toolCount?: number;
connectedServers?: string[];
}) => void;
private onHealthCheckChange?: (serverId: string, state: HealthCheckState) => void;
/**
*
*
* Store Integration
*
*
*/
/**
* Sets callback for state changes.
* Called by mcpStore to sync reactive state.
*/
setStateChangeCallback(
callback: (state: {
isInitializing?: boolean;
error?: string | null;
toolCount?: number;
connectedServers?: string[];
}) => void
): void {
this.onStateChange = callback;
}
/**
* Set callback for health check state changes
*/
setHealthCheckCallback(callback: (serverId: string, state: HealthCheckState) => void): void {
this.onHealthCheckChange = callback;
}
private notifyStateChange(state: Parameters<NonNullable<typeof this.onStateChange>>[0]): void {
this.onStateChange?.(state);
}
private notifyHealthCheck(serverId: string, state: HealthCheckState): void {
this.onHealthCheckChange?.(serverId, state);
}
/**
*
*
* Lifecycle
*
*
*/
/**
* Ensures MCP is initialized with current config.
* Handles config changes by reinitializing as needed.
* @param perChatOverrides - Optional per-chat MCP server overrides
*/
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
if (!browser) return false;
const mcpConfig = buildMcpClientConfig(config(), perChatOverrides);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
if (!signature) {
await this.shutdown();
return false;
}
if (this.isInitialized && this.configSignature === signature) {
return true;
}
if (this.initPromise && this.configSignature === signature) {
return this.initPromise;
}
if (this.connections.size > 0 || this.initPromise) {
await this.shutdown();
}
return this.initialize(signature, mcpConfig!);
}
/**
* Initialize connections to all configured MCP servers.
*/
private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise<boolean> {
console.log('[MCPClient] Starting initialization...');
this.notifyStateChange({ isInitializing: true, error: null });
this.configSignature = signature;
const serverEntries = Object.entries(mcpConfig.servers);
if (serverEntries.length === 0) {
console.log('[MCPClient] No servers configured');
this.notifyStateChange({ isInitializing: false, toolCount: 0, connectedServers: [] });
return false;
}
this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries);
return this.initPromise;
}
private async doInitialize(
signature: string,
mcpConfig: MCPClientConfig,
serverEntries: [string, MCPClientConfig['servers'][string]][]
): Promise<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 connection = await MCPService.connect(name, serverConfig, clientInfo, capabilities);
return { name, connection };
})
);
if (this.configSignature !== signature) {
console.log('[MCPClient] Config changed during init, aborting');
for (const result of results) {
if (result.status === 'fulfilled') {
await MCPService.disconnect(result.value.connection).catch(console.warn);
}
}
return false;
}
for (const result of results) {
if (result.status === 'fulfilled') {
const { name, connection } = result.value;
this.connections.set(name, connection);
for (const tool of connection.tools) {
if (this.toolsIndex.has(tool.name)) {
console.warn(
`[MCPClient] Tool name conflict: "${tool.name}" exists in ` +
`"${this.toolsIndex.get(tool.name)}" and "${name}". ` +
`Using tool from "${name}".`
);
}
this.toolsIndex.set(tool.name, name);
}
} else {
console.error(`[MCPClient] Failed to connect:`, result.reason);
}
}
const successCount = this.connections.size;
const totalCount = serverEntries.length;
if (successCount === 0 && totalCount > 0) {
const error = 'All MCP server connections failed';
this.notifyStateChange({
isInitializing: false,
error,
toolCount: 0,
connectedServers: []
});
this.initPromise = null;
return false;
}
this.notifyStateChange({
isInitializing: false,
error: null,
toolCount: this.toolsIndex.size,
connectedServers: Array.from(this.connections.keys())
});
console.log(
`[MCPClient] Initialization complete: ${successCount}/${totalCount} servers connected, ` +
`${this.toolsIndex.size} tools available`
);
this.initPromise = null;
return true;
}
/**
* Shutdown all MCP connections and clear state.
*/
async shutdown(): Promise<void> {
if (this.initPromise) {
await this.initPromise.catch(() => {});
this.initPromise = null;
}
if (this.connections.size === 0) {
return;
}
console.log(`[MCPClient] Shutting down ${this.connections.size} connections...`);
await Promise.all(
Array.from(this.connections.values()).map((conn) =>
MCPService.disconnect(conn).catch((error) => {
console.warn(`[MCPClient] Error disconnecting ${conn.serverName}:`, error);
})
)
);
this.connections.clear();
this.toolsIndex.clear();
this.configSignature = null;
this.notifyStateChange({
isInitializing: false,
error: null,
toolCount: 0,
connectedServers: []
});
console.log('[MCPClient] Shutdown complete');
}
/**
*
*
* Tool Definitions
*
*
*/
/**
* Returns tools in OpenAI function calling format.
* Ready to be sent to /v1/chat/completions API.
*/
getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [];
for (const connection of this.connections.values()) {
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
type: 'object',
properties: {},
required: []
};
const normalizedSchema = this.normalizeSchemaProperties(rawSchema);
tools.push({
type: 'function' as const,
function: {
name: tool.name,
description: tool.description,
parameters: normalizedSchema
}
});
}
}
return tools;
}
/**
* Normalize JSON Schema properties to ensure all have explicit types.
* Infers type from default value if missing - fixes compatibility with
* llama.cpp which requires explicit types in tool schemas.
*/
private normalizeSchemaProperties(schema: Record<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 };
// Infer type from default if missing
if (!normalizedProp.type && normalizedProp.default !== undefined) {
const defaultVal = normalizedProp.default;
if (typeof defaultVal === 'string') {
normalizedProp.type = 'string';
} else if (typeof defaultVal === 'number') {
normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number';
} else if (typeof defaultVal === 'boolean') {
normalizedProp.type = 'boolean';
} else if (Array.isArray(defaultVal)) {
normalizedProp.type = 'array';
} else if (typeof defaultVal === 'object' && defaultVal !== null) {
normalizedProp.type = 'object';
}
}
if (normalizedProp.properties) {
Object.assign(
normalizedProp,
this.normalizeSchemaProperties(normalizedProp as Record<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;
}
/**
*
*
* Tool Queries
*
*
*/
/**
* Returns names of all available tools.
*/
getToolNames(): string[] {
return Array.from(this.toolsIndex.keys());
}
/**
* Check if a tool exists.
*/
hasTool(toolName: string): boolean {
return this.toolsIndex.has(toolName);
}
/**
* Get which server provides a specific tool.
*/
getToolServer(toolName: string): string | undefined {
return this.toolsIndex.get(toolName);
}
/**
*
*
* Tool Execution
*
*
*/
/**
* Executes a tool call, automatically routing to the appropriate server.
* Accepts the OpenAI-style tool call format.
* @param toolCall - Tool call with function name and arguments
* @param signal - Optional abort signal
* @returns Tool execution result
*/
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
const toolName = toolCall.function.name;
const serverName = this.toolsIndex.get(toolName);
if (!serverName) {
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
}
const connection = this.connections.get(serverName);
if (!connection) {
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
}
const updatedStats = incrementMcpServerUsage(config(), serverName);
settingsStore.updateConfig('mcpServerUsageStats', updatedStats);
const args = this.parseToolArguments(toolCall.function.arguments);
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
}
/**
* Executes a tool by name with arguments object.
* Simpler interface for direct tool calls.
* @param toolName - Name of the tool to execute
* @param args - Tool arguments as key-value pairs
* @param signal - Optional abort signal
*/
async executeToolByName(
toolName: string,
args: Record<string, unknown>,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
const serverName = this.toolsIndex.get(toolName);
if (!serverName) {
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
}
const connection = this.connections.get(serverName);
if (!connection) {
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
}
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
}
private parseToolArguments(args: string | Record<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 MCPError(
`Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
-32602
);
}
return parsed as Record<string, unknown>;
} catch (error) {
if (error instanceof MCPError) {
throw error;
}
throw new MCPError(
`Failed to parse tool arguments as JSON: ${(error as Error).message}`,
-32700
);
}
}
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
return args;
}
throw new MCPError(`Invalid tool arguments type: ${typeof args}`, -32602);
}
/**
*
*
* Health Checks
*
*
*/
private parseHeaders(headersJson?: string): Record<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('[MCPClient] Failed to parse custom headers JSON:', headersJson);
}
return undefined;
}
/**
* Run health check for a specific server configuration.
* Creates a temporary connection to test connectivity and list tools.
*/
async runHealthCheck(server: HealthCheckParams): Promise<void> {
const trimmedUrl = server.url.trim();
if (!trimmedUrl) {
this.notifyHealthCheck(server.id, {
status: 'error',
message: 'Please enter a server URL first.'
});
return;
}
this.notifyHealthCheck(server.id, { status: 'loading' });
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
const headers = this.parseHeaders(server.headers);
try {
const connection = await MCPService.connect(
server.id,
{
url: trimmedUrl,
transport: detectMcpTransportFromUrl(trimmedUrl),
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
requestTimeoutMs: timeoutMs,
headers
},
DEFAULT_MCP_CONFIG.clientInfo,
DEFAULT_MCP_CONFIG.capabilities
);
const tools = connection.tools.map((tool) => ({
name: tool.name,
description: tool.description
}));
this.notifyHealthCheck(server.id, { status: 'success', tools });
await MCPService.disconnect(connection);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred';
this.notifyHealthCheck(server.id, { status: 'error', message });
}
}
/**
*
*
* Status Getters
*
*
*/
get isInitialized(): boolean {
return this.connections.size > 0;
}
get connectedServerCount(): number {
return this.connections.size;
}
get connectedServerNames(): string[] {
return Array.from(this.connections.keys());
}
get toolCount(): number {
return this.toolsIndex.size;
}
/**
* Get status of all connected servers.
*/
getServersStatus(): ServerStatus[] {
const statuses: ServerStatus[] = [];
for (const [name, connection] of this.connections) {
statuses.push({
name,
isConnected: true,
toolCount: connection.tools.length,
error: undefined
});
}
return statuses;
}
}
export const mcpClient = new MCPClient();

View File

@ -1,190 +0,0 @@
import type {
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
ApiChatCompletionStreamChunk
} from '$lib/types/api';
import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat';
import { mergeToolCallDeltas, extractModelName } from '$lib/utils/chat-stream';
import type { AgenticChatCompletionRequest } from '$lib/types/agentic';
export type OpenAISseCallbacks = {
onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (serializedToolCalls: string) => void;
onModel?: (model: string) => void;
onFirstValidChunk?: () => void;
onProcessingUpdate?: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => void;
};
export type OpenAISseTurnResult = {
content: string;
reasoningContent?: string;
toolCalls: ApiChatCompletionToolCall[];
finishReason?: string | null;
timings?: ChatMessageTimings;
};
export type OpenAISseClientOptions = {
url: string;
buildHeaders?: () => Record<string, string>;
};
export class OpenAISseClient {
constructor(private readonly options: OpenAISseClientOptions) {}
async stream(
request: AgenticChatCompletionRequest,
callbacks: OpenAISseCallbacks = {},
abortSignal?: AbortSignal
): Promise<OpenAISseTurnResult> {
const response = await fetch(this.options.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.options.buildHeaders?.() ?? {})
},
body: JSON.stringify(request),
signal: abortSignal
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `LLM request failed (${response.status})`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('LLM response stream is not available');
}
return this.consumeStream(reader, callbacks, abortSignal);
}
private async consumeStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
callbacks: OpenAISseCallbacks,
abortSignal?: AbortSignal
): Promise<OpenAISseTurnResult> {
const decoder = new TextDecoder();
let buffer = '';
let aggregatedContent = '';
let aggregatedReasoning = '';
let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
let hasOpenToolCallBatch = false;
let toolCallIndexOffset = 0;
let finishReason: string | null | undefined;
let lastTimings: ChatMessageTimings | undefined;
let modelEmitted = false;
let firstValidChunkEmitted = false;
const finalizeToolCallBatch = () => {
if (!hasOpenToolCallBatch) return;
toolCallIndexOffset = aggregatedToolCalls.length;
hasOpenToolCallBatch = false;
};
const processToolCalls = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
if (!toolCalls || toolCalls.length === 0) {
return;
}
aggregatedToolCalls = mergeToolCallDeltas(
aggregatedToolCalls,
toolCalls,
toolCallIndexOffset
);
if (aggregatedToolCalls.length === 0) {
return;
}
hasOpenToolCallBatch = true;
};
try {
while (true) {
if (abortSignal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) {
continue;
}
const payload = line.slice(6);
if (payload === '[DONE]' || payload.trim().length === 0) {
continue;
}
let chunk: ApiChatCompletionStreamChunk;
try {
chunk = JSON.parse(payload) as ApiChatCompletionStreamChunk;
} catch (error) {
console.error('[Agentic][SSE] Failed to parse chunk:', error);
continue;
}
if (!firstValidChunkEmitted && chunk.object === 'chat.completion.chunk') {
firstValidChunkEmitted = true;
callbacks.onFirstValidChunk?.();
}
const choice = chunk.choices?.[0];
const delta = choice?.delta;
finishReason = choice?.finish_reason ?? finishReason;
if (!modelEmitted) {
const chunkModel = extractModelName(chunk);
if (chunkModel) {
modelEmitted = true;
callbacks.onModel?.(chunkModel);
}
}
if (chunk.timings || chunk.prompt_progress) {
callbacks.onProcessingUpdate?.(chunk.timings, chunk.prompt_progress);
if (chunk.timings) {
lastTimings = chunk.timings;
}
}
if (delta?.content) {
finalizeToolCallBatch();
aggregatedContent += delta.content;
callbacks.onChunk?.(delta.content);
}
if (delta?.reasoning_content) {
finalizeToolCallBatch();
aggregatedReasoning += delta.reasoning_content;
callbacks.onReasoningChunk?.(delta.reasoning_content);
}
processToolCalls(delta?.tool_calls);
}
}
finalizeToolCallBatch();
} catch (error) {
if ((error as Error).name === 'AbortError') {
throw error;
}
throw error instanceof Error ? error : new Error('LLM stream error');
} finally {
reader.releaseLock();
}
return {
content: aggregatedContent,
reasoningContent: aggregatedReasoning || undefined,
toolCalls: aggregatedToolCalls,
finishReason,
timings: lastTimings
};
}
}

View File

@ -13,13 +13,18 @@
SyntaxHighlightedCode
} from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte';
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
import { Wrench, Loader2 } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
import { AGENTIC_TAGS, AGENTIC_REGEX } from '$lib/constants/agentic';
import { formatJsonPretty } from '$lib/utils/formatters';
import { decodeBase64 } from '$lib/utils';
import type { ChatMessageToolCallTiming } from '$lib/types/chat';
interface Props {
content: string;
isStreaming?: boolean;
toolCallTimings?: ChatMessageToolCallTiming[];
}
interface AgenticSection {
@ -30,10 +35,18 @@
toolResult?: string;
}
let { content }: Props = $props();
let { content, isStreaming = false, toolCallTimings = [] }: Props = $props();
const sections = $derived(parseAgenticContent(content));
// Get timing for a specific tool call by index (completed tool calls only)
function getToolCallTiming(toolCallIndex: number): ChatMessageToolCallTiming | undefined {
return toolCallTimings[toolCallIndex];
}
// Get streaming tool call from reactive store (not from content markers)
const streamingToolCall = $derived(isStreaming ? agenticStreamingToolCall() : null);
let expandedStates: Record<number, boolean> = $state({});
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
@ -74,12 +87,7 @@
const toolName = match[1];
const toolArgsBase64 = match[2];
let toolArgs = '';
try {
toolArgs = decodeURIComponent(escape(atob(toolArgsBase64)));
} catch {
toolArgs = toolArgsBase64;
}
const toolArgs = decodeBase64(toolArgsBase64);
const toolResult = match[3].replace(/^\n+|\n+$/g, '');
sections.push({
@ -112,12 +120,7 @@
const toolName = pendingMatch[1];
const toolArgsBase64 = pendingMatch[2];
let toolArgs = '';
try {
toolArgs = decodeURIComponent(escape(atob(toolArgsBase64)));
} catch {
toolArgs = toolArgsBase64;
}
const toolArgs = decodeBase64(toolArgsBase64);
// Capture streaming result content (everything after args marker)
const streamingResult = (pendingMatch[3] || '').replace(/^\n+|\n+$/g, '');
@ -138,23 +141,7 @@
}
const partialArgsBase64 = partialWithNameMatch[2] || '';
let partialArgs = '';
if (partialArgsBase64) {
try {
// Try to decode - may fail if incomplete base64
partialArgs = decodeURIComponent(escape(atob(partialArgsBase64)));
} catch {
// If decoding fails, try padding the base64
try {
const padded =
partialArgsBase64 + '=='.slice(0, (4 - (partialArgsBase64.length % 4)) % 4);
partialArgs = decodeURIComponent(escape(atob(padded)));
} catch {
// Show raw base64 if all decoding fails
partialArgs = '';
}
}
}
const partialArgs = decodeBase64(partialArgsBase64);
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
@ -214,46 +201,25 @@
<div class="agentic-text">
<MarkdownContent content={section.content} />
</div>
{:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
<CollapsibleContentBlock
open={isExpanded(index, true)}
class="my-2"
icon={Loader2}
iconClass="h-4 w-4 animate-spin"
title={section.toolName || 'Tool call'}
subtitle="streaming..."
onToggle={() => toggleExpanded(index, true)}
>
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Arguments:</span>
<Loader2 class="h-3 w-3 animate-spin" />
</div>
{#if section.toolArgs}
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
language="json"
maxHeight="20rem"
class="text-xs"
/>
{:else}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Receiving arguments...
</div>
{/if}
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING}
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
{@const toolIcon = isPending ? Loader2 : Wrench}
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
{@const toolCallIndex =
sections.slice(0, index + 1).filter((s) => s.type === AgenticSectionType.TOOL_CALL).length -
1}
{@const timing = !isPending ? getToolCallTiming(toolCallIndex) : undefined}
<CollapsibleContentBlock
open={isExpanded(index, isPending)}
class="my-2"
icon={toolIcon}
iconClass={toolIconClass}
title={section.toolName || ''}
subtitle={isPending ? 'executing...' : undefined}
subtitle={isPending
? 'executing...'
: timing
? `${(timing.duration_ms / 1000).toFixed(2)}s`
: undefined}
onToggle={() => toggleExpanded(index, isPending)}
>
{#if section.toolArgs && section.toolArgs !== '{}'}
@ -289,6 +255,37 @@
</CollapsibleContentBlock>
{/if}
{/each}
{#if streamingToolCall}
<CollapsibleContentBlock
open={true}
class="my-2"
icon={Loader2}
iconClass="h-4 w-4 animate-spin"
title={streamingToolCall.name || 'Tool call'}
subtitle="streaming..."
onToggle={() => {}}
>
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Arguments:</span>
<Loader2 class="h-3 w-3 animate-spin" />
</div>
{#if streamingToolCall.arguments}
<SyntaxHighlightedCode
code={formatJsonPretty(streamingToolCall.arguments)}
language="json"
maxHeight="20rem"
class="text-xs"
/>
{:else}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Receiving arguments...
</div>
{/if}
</div>
</CollapsibleContentBlock>
{/if}
</div>
<style>

View File

@ -10,7 +10,8 @@
} from '$lib/components/app';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
import { isLoading } from '$lib/stores/chat.svelte';
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
import { fade } from 'svelte/transition';
import { Check, X } from '@lucide/svelte';
@ -82,25 +83,18 @@
thinkingContent
}: Props = $props();
// Check if content contains agentic tool call markers
const isAgenticContent = $derived(
const hasAgenticMarkers = $derived(
messageContent?.includes('<<<AGENTIC_TOOL_CALL_START>>>') ?? false
);
const hasStreamingToolCall = $derived(isChatStreaming() && agenticStreamingToolCall() !== null);
const isAgenticContent = $derived(hasAgenticMarkers || hasStreamingToolCall);
const processingState = useProcessingState();
// Local state for raw output toggle (per message)
let showRawOutput = $state(false);
let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode());
let displayedModel = $derived((): string | null => {
if (message.model) {
return message.model;
}
let showRawOutput = $state(false);
return null;
});
let displayedModel = $derived(message.model ?? null);
const { handleModelChange } = useModelChangeValidation({
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
@ -108,9 +102,7 @@
});
function handleCopyModel() {
const model = displayedModel();
void copyToClipboard(model ?? '');
void copyToClipboard(displayedModel ?? '');
}
$effect(() => {
@ -191,7 +183,11 @@
{#if showRawOutput}
<pre class="raw-output">{messageContent || ''}</pre>
{:else if isAgenticContent}
<AgenticContent content={messageContent || ''} />
<AgenticContent
content={messageContent || ''}
isStreaming={isChatStreaming()}
toolCallTimings={message.timings?.agentic?.toolCalls}
/>
{:else}
<MarkdownContent content={messageContent || ''} />
{/if}
@ -202,17 +198,17 @@
{/if}
<div class="info my-6 grid gap-4">
{#if displayedModel()}
{#if displayedModel}
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
{#if isRouter}
<ModelsSelector
currentModel={displayedModel()}
currentModel={displayedModel}
onModelChange={handleModelChange}
disabled={isLoading()}
upToMessageId={message.id}
/>
{:else}
<ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
{/if}
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
@ -221,6 +217,7 @@
promptMs={message.timings.prompt_ms}
predictedTokens={message.timings.predicted_n}
predictedMs={message.timings.predicted_ms}
agenticTimings={message.timings.agentic}
/>
{:else if isLoading() && currentConfig.showMessageStats}
{@const liveStats = processingState.getLiveProcessingStats()}

View File

@ -1,20 +1,19 @@
<script lang="ts">
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
import { BadgeChatStatistic } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums';
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
interface Props {
predictedTokens?: number;
predictedMs?: number;
promptTokens?: number;
promptMs?: number;
// Live mode: when true, shows stats during streaming
isLive?: boolean;
// Whether prompt processing is still in progress
isProcessingPrompt?: boolean;
// Initial view to show (defaults to READING in live mode)
initialView?: ChatMessageStatsView;
agenticTimings?: ChatMessageAgenticTimings;
}
let {
@ -24,7 +23,8 @@
promptMs,
isLive = false,
isProcessingPrompt = false,
initialView = ChatMessageStatsView.GENERATION
initialView = ChatMessageStatsView.GENERATION,
agenticTimings
}: Props = $props();
let activeView: ChatMessageStatsView = $state(initialView);
@ -80,6 +80,26 @@
// In live mode, generation tab is disabled until we have generation stats
let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
let hasAgenticStats = $derived(agenticTimings !== undefined && agenticTimings.toolCallsCount > 0);
let agenticToolsPerSecond = $derived(
hasAgenticStats && agenticTimings!.toolsMs > 0
? (agenticTimings!.toolCallsCount / agenticTimings!.toolsMs) * 1000
: 0
);
let agenticToolsTimeInSeconds = $derived(
hasAgenticStats ? (agenticTimings!.toolsMs / 1000).toFixed(2) : '0.00'
);
let agenticTotalTimeMs = $derived(
hasAgenticStats
? agenticTimings!.toolsMs + agenticTimings!.llm.predicted_ms + agenticTimings!.llm.prompt_ms
: 0
);
let agenticTotalTimeInSeconds = $derived((agenticTotalTimeMs / 1000).toFixed(2));
</script>
<div class="inline-flex items-center text-xs text-muted-foreground">
@ -129,6 +149,44 @@
</p>
</Tooltip.Content>
</Tooltip.Root>
{#if hasAgenticStats}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
ChatMessageStatsView.TOOLS
? 'bg-background text-foreground shadow-sm'
: 'hover:text-foreground'}"
onclick={() => (activeView = ChatMessageStatsView.TOOLS)}
>
<Wrench class="h-3 w-3" />
<span class="sr-only">Tools</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool calls</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
ChatMessageStatsView.SUMMARY
? 'bg-background text-foreground shadow-sm'
: 'hover:text-foreground'}"
onclick={() => (activeView = ChatMessageStatsView.SUMMARY)}
>
<Layers class="h-3 w-3" />
<span class="sr-only">Summary</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Agentic summary</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
<div class="flex items-center gap-1 px-2">
@ -148,9 +206,47 @@
<BadgeChatStatistic
class="bg-transparent"
icon={Gauge}
value="{tokensPerSecond.toFixed(2)} tokens/s"
value="{tokensPerSecond.toFixed(2)} t/s"
tooltipLabel="Generation speed"
/>
{:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats}
<BadgeChatStatistic
class="bg-transparent"
icon={Wrench}
value="{agenticTimings!.toolCallsCount} calls"
tooltipLabel="Tool calls executed"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
value="{agenticToolsTimeInSeconds}s"
tooltipLabel="Tool execution time"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Gauge}
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
tooltipLabel="Tool execution rate"
/>
{:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats}
<BadgeChatStatistic
class="bg-transparent"
icon={Layers}
value="{agenticTimings!.turns} turns"
tooltipLabel="Agentic turns (LLM calls)"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={WholeWord}
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
tooltipLabel="Total tokens generated"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
value="{agenticTotalTimeInSeconds}s"
tooltipLabel="Total time (LLM + tools)"
/>
{:else if hasPromptStats}
<BadgeChatStatistic
class="bg-transparent"

View File

@ -8,7 +8,7 @@
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { parseMcpServerSettings, parseMcpServerUsageStats } from '$lib/config/mcp';
import { parseMcpServerSettings, parseMcpServerUsageStats } from '$lib/utils/mcp';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
import {
mcpGetHealthCheckState,

View File

@ -2,7 +2,7 @@
import { Plus, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { parseMcpServerSettings } from '$lib/config/mcp';
import { parseMcpServerSettings } from '$lib/utils/mcp';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
import type { SettingsConfigType } from '$lib/types/settings';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';

View File

@ -370,7 +370,7 @@
: 'text-muted-foreground',
isOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 6.5rem), 24rem)"
style="max-width: min(calc(100cqw - 6.5rem), 20rem)"
disabled={disabled || updating}
>
<Package class="h-3.5 w-3.5" />

View File

@ -1,37 +0,0 @@
import { hasEnabledMcpServers } from './mcp';
import type { SettingsConfigType } from '$lib/types/settings';
import type { AgenticConfig } from '$lib/types/agentic';
import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
import { normalizePositiveNumber } from '$lib/utils/number';
/**
* 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
*/
export function getAgenticConfig(
settings: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): AgenticConfig {
const maxTurns = normalizePositiveNumber(
settings.agenticMaxTurns,
DEFAULT_AGENTIC_CONFIG.maxTurns
);
const maxToolPreviewLines = normalizePositiveNumber(
settings.agenticMaxToolPreviewLines,
DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines
);
const filterReasoningAfterFirstTurn =
typeof settings.agenticFilterReasoningAfterFirstTurn === 'boolean'
? settings.agenticFilterReasoningAfterFirstTurn
: DEFAULT_AGENTIC_CONFIG.filterReasoningAfterFirstTurn;
return {
enabled: hasEnabledMcpServers(settings, perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
maxTurns,
maxToolPreviewLines,
filterReasoningAfterFirstTurn
};
}

View File

@ -1,190 +0,0 @@
import type {
MCPClientConfig,
MCPServerConfig,
MCPServerSettingsEntry,
McpServerUsageStats
} from '$lib/types/mcp';
import type { SettingsConfigType } from '$lib/types/settings';
import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { detectMcpTransportFromUrl, generateMcpServerId } from '$lib/utils/mcp';
import { normalizePositiveNumber } from '$lib/utils/number';
export function parseMcpServerSettings(
rawServers: unknown,
fallbackRequestTimeoutSeconds = DEFAULT_MCP_CONFIG.requestTimeoutSeconds
): MCPServerSettingsEntry[] {
if (!rawServers) return [];
let parsed: unknown;
if (typeof rawServers === 'string') {
const trimmed = rawServers.trim();
if (!trimmed) return [];
try {
parsed = JSON.parse(trimmed);
} catch (error) {
console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
return [];
}
} else {
parsed = rawServers;
}
if (!Array.isArray(parsed)) return [];
return parsed.map((entry, index) => {
const requestTimeoutSeconds = normalizePositiveNumber(
(entry as { requestTimeoutSeconds?: unknown })?.requestTimeoutSeconds,
fallbackRequestTimeoutSeconds
);
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
return {
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
url,
requestTimeoutSeconds,
headers: headers || undefined
} satisfies MCPServerSettingsEntry;
});
}
function buildServerConfig(
entry: MCPServerSettingsEntry,
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
): MCPServerConfig | undefined {
if (!entry?.url) {
return undefined;
}
// Parse custom headers if provided
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, ignoring:', entry.headers);
}
}
return {
url: entry.url,
transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
headers
};
}
/**
* Checks if a server is enabled considering per-chat overrides.
* Per-chat override takes precedence over global setting.
*/
function isServerEnabled(
server: MCPServerSettingsEntry,
perChatOverrides?: McpServerOverride[]
): boolean {
if (perChatOverrides) {
const override = perChatOverrides.find((o) => o.serverId === server.id);
if (override !== undefined) {
return override.enabled;
}
}
return server.enabled;
}
/**
* Builds MCP client configuration from settings.
* Returns undefined if no valid servers are configured.
* @param config - Global settings configuration
* @param perChatOverrides - Optional per-chat server overrides
*/
export function buildMcpClientConfig(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseMcpServerSettings(config.mcpServers);
if (!rawServers.length) {
return undefined;
}
const servers: Record<string, MCPServerConfig> = {};
for (const [index, entry] of rawServers.entries()) {
if (!isServerEnabled(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
};
}
export function hasEnabledMcpServers(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): boolean {
return Boolean(buildMcpClientConfig(config, perChatOverrides));
}
// ─────────────────────────────────────────────────────────────────────────────
// MCP Server Usage Stats
// ─────────────────────────────────────────────────────────────────────────────
/**
* Parse MCP server usage stats from settings.
*/
export function parseMcpServerUsageStats(rawStats: unknown): McpServerUsageStats {
if (!rawStats) return {};
if (typeof rawStats === 'string') {
const trimmed = rawStats.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as McpServerUsageStats;
}
} catch {
console.warn('[MCP] Failed to parse mcpServerUsageStats JSON, ignoring value');
}
}
return {};
}
/**
* Get usage count for a specific server.
*/
export function getMcpServerUsageCount(config: SettingsConfigType, serverId: string): number {
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
return stats[serverId] || 0;
}
/**
* Increment usage count for a server and return updated stats JSON.
*/
export function incrementMcpServerUsage(config: SettingsConfigType, serverId: string): string {
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
stats[serverId] = (stats[serverId] || 0) + 1;
return JSON.stringify(stats);
}

View File

@ -1,6 +1,8 @@
export enum ChatMessageStatsView {
GENERATION = 'generation',
READING = 'reading'
READING = 'reading',
TOOLS = 'tools',
SUMMARY = 'summary'
}
/**

View File

@ -163,7 +163,7 @@ export function useProcessingState(): UseProcessingStateReturn {
}
if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} tokens/sec`);
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} t/s`);
}
if (stateToUse.speculative) {

View File

@ -1,408 +0,0 @@
/**
* MCPHostManager - Multi-server MCP connection aggregator
*
* Implements the "Host" role in MCP architecture, coordinating multiple server
* connections and providing a unified interface for tool operations.
*
* **Architecture & Relationships:**
* - **MCPHostManager** (this class): Host-level coordination layer
* - Coordinates multiple Client instances (MCPServerConnection)
* - Aggregates tools from all connected servers
* - Routes tool calls to the appropriate server
* - Manages lifecycle of all connections
*
* - **MCPServerConnection**: Individual server connection wrapper
* - **agenticStore**: Uses MCPHostManager for tool execution in agentic loops
*
* **Key Responsibilities:**
* - Parallel server initialization and shutdown
* - Tool name conflict detection and resolution
* - OpenAI-compatible tool definition generation
* - Automatic tool-to-server routing
*/
import { MCPServerConnection } from './server-connection';
import type { ToolExecutionResult } from '$lib/types/mcp';
import type {
MCPToolCall,
MCPHostManagerConfig,
OpenAIToolDefinition,
ServerStatus
} from '$lib/types/mcp';
import { MCPError } from '$lib/errors';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
/**
* Manages multiple MCP server connections and provides unified tool access.
*/
export class MCPHostManager {
private connections = new Map<string, MCPServerConnection>();
private toolsIndex = new Map<string, string>(); // toolName → serverName
private _isInitialized = false;
private _initializationError: Error | null = null;
// ─────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────
async initialize(config: MCPHostManagerConfig): Promise<void> {
console.log('[MCPHost] Starting initialization...');
// Clean up previous connections
await this.shutdown();
const serverEntries = Object.entries(config.servers);
if (serverEntries.length === 0) {
console.log('[MCPHost] No servers configured');
this._isInitialized = true;
return;
}
// Connect to each server in parallel
const connectionPromises = serverEntries.map(async ([name, serverConfig]) => {
try {
const connection = new MCPServerConnection({
name,
server: serverConfig,
clientInfo: config.clientInfo,
capabilities: config.capabilities
});
await connection.connect();
return { name, connection, success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[MCPHost] Failed to connect to ${name}:`, errorMessage);
return { name, connection: null, success: false, error: errorMessage };
}
});
const results = await Promise.all(connectionPromises);
// Store successful connections
for (const result of results) {
if (result.success && result.connection) {
this.connections.set(result.name, result.connection);
}
}
// Build tools index
this.rebuildToolsIndex();
const successCount = this.connections.size;
const totalCount = serverEntries.length;
if (successCount === 0 && totalCount > 0) {
this._initializationError = new Error('All MCP server connections failed');
throw this._initializationError;
}
this._isInitialized = true;
this._initializationError = null;
console.log(
`[MCPHost] Initialization complete: ${successCount}/${totalCount} servers connected, ` +
`${this.toolsIndex.size} tools available`
);
}
async shutdown(): Promise<void> {
if (this.connections.size === 0) {
return;
}
console.log(`[MCPHost] Shutting down ${this.connections.size} connections...`);
const shutdownPromises = Array.from(this.connections.values()).map((conn) =>
conn.disconnect().catch((error) => {
console.warn(`[MCPHost] Error disconnecting ${conn.serverName}:`, error);
})
);
await Promise.all(shutdownPromises);
this.connections.clear();
this.toolsIndex.clear();
this._isInitialized = false;
console.log('[MCPHost] Shutdown complete');
}
private rebuildToolsIndex(): void {
this.toolsIndex.clear();
for (const [serverName, connection] of this.connections) {
for (const tool of connection.tools) {
// Check for name conflicts
if (this.toolsIndex.has(tool.name)) {
console.warn(
`[MCPHost] Tool name conflict: "${tool.name}" exists in ` +
`"${this.toolsIndex.get(tool.name)}" and "${serverName}". ` +
`Using tool from "${serverName}".`
);
}
this.toolsIndex.set(tool.name, serverName);
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// Tool Aggregation
// ─────────────────────────────────────────────────────────────────────────
/**
* Returns ALL tools from ALL connected servers.
* This is what we send to LLM as available tools.
*/
getAllTools(): Tool[] {
const allTools: Tool[] = [];
for (const connection of this.connections.values()) {
allTools.push(...connection.tools);
}
return allTools;
}
/**
* Normalize JSON Schema properties to ensure all have explicit types.
* Infers type from default value if missing - fixes compatibility with
* llama.cpp which requires explicit types in tool schemas.
*/
private normalizeSchemaProperties(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema;
const normalized = { ...schema };
// Process properties object
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 };
// Infer type from default if missing
if (!normalizedProp.type && normalizedProp.default !== undefined) {
const defaultVal = normalizedProp.default;
if (typeof defaultVal === 'string') {
normalizedProp.type = 'string';
} else if (typeof defaultVal === 'number') {
normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number';
} else if (typeof defaultVal === 'boolean') {
normalizedProp.type = 'boolean';
} else if (Array.isArray(defaultVal)) {
normalizedProp.type = 'array';
} else if (typeof defaultVal === 'object' && defaultVal !== null) {
normalizedProp.type = 'object';
}
}
// Recursively normalize nested schemas
if (normalizedProp.properties) {
Object.assign(
normalizedProp,
this.normalizeSchemaProperties(normalizedProp as Record<string, unknown>)
);
}
// Normalize items in array schemas
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;
}
/**
* Returns tools in OpenAI function calling format.
* Ready to be sent to /v1/chat/completions API.
*/
getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
return this.getAllTools().map((tool) => {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
type: 'object',
properties: {},
required: []
};
// Normalize schema to fix missing types
const normalizedSchema = this.normalizeSchemaProperties(rawSchema);
return {
type: 'function' as const,
function: {
name: tool.name,
description: tool.description,
parameters: normalizedSchema
}
};
});
}
/**
* Returns names of all available tools.
*/
getToolNames(): string[] {
return Array.from(this.toolsIndex.keys());
}
/**
* Check if a tool exists.
*/
hasTool(toolName: string): boolean {
return this.toolsIndex.has(toolName);
}
/**
* Get which server provides a specific tool.
*/
getToolServer(toolName: string): string | undefined {
return this.toolsIndex.get(toolName);
}
// ─────────────────────────────────────────────────────────────────────────
// Tool Execution
// ─────────────────────────────────────────────────────────────────────────
/**
* Execute a tool call, automatically routing to the appropriate server.
* Accepts the OpenAI-style tool call format.
*/
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
const toolName = toolCall.function.name;
// Find which server handles this tool
const serverName = this.toolsIndex.get(toolName);
if (!serverName) {
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
}
const connection = this.connections.get(serverName);
if (!connection) {
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
}
// Parse arguments
const args = this.parseToolArguments(toolCall.function.arguments);
// Delegate to the appropriate server
return connection.callTool({ name: toolName, arguments: args }, signal);
}
/**
* Execute a tool by name with arguments object.
* Simpler interface for direct tool calls.
*/
async executeToolByName(
toolName: string,
args: Record<string, unknown>,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
const serverName = this.toolsIndex.get(toolName);
if (!serverName) {
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
}
const connection = this.connections.get(serverName);
if (!connection) {
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
}
return connection.callTool({ 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 MCPError(
`Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
-32602
);
}
return parsed as Record<string, unknown>;
} catch (error) {
if (error instanceof MCPError) {
throw error;
}
throw new MCPError(
`Failed to parse tool arguments as JSON: ${(error as Error).message}`,
-32700
);
}
}
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
return args;
}
throw new MCPError(`Invalid tool arguments type: ${typeof args}`, -32602);
}
// ─────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────
get isInitialized(): boolean {
return this._isInitialized;
}
get initializationError(): Error | null {
return this._initializationError;
}
get connectedServerCount(): number {
return this.connections.size;
}
get connectedServerNames(): string[] {
return Array.from(this.connections.keys());
}
get toolCount(): number {
return this.toolsIndex.size;
}
/**
* Get status of all configured servers.
*/
getServersStatus(): ServerStatus[] {
const statuses: ServerStatus[] = [];
for (const [name, connection] of this.connections) {
statuses.push({
name,
isConnected: connection.isConnected,
toolCount: connection.tools.length,
error: connection.lastError?.message
});
}
return statuses;
}
/**
* Get a specific server connection (for advanced use cases).
*/
getServerConnection(name: string): MCPServerConnection | undefined {
return this.connections.get(name);
}
}

View File

@ -1,6 +0,0 @@
// New architecture exports
export { MCPHostManager } from './host-manager';
export { MCPServerConnection } from './server-connection';
// Errors
export { MCPError } from '$lib/errors';

View File

@ -1,286 +0,0 @@
/**
* MCPServerConnection - Single MCP server connection wrapper
*
* Wraps the MCP SDK Client for a single server connection. Each instance represents
* one isolated connection with its own transport, capabilities, and lifecycle.
*
* **Architecture:**
* - One MCPServerConnection = one connection = one SDK Client
* - Server isolation - each has its own transport and capabilities
* - Independent lifecycle (connect, disconnect)
*
* **Key Responsibilities:**
* - Transport creation (WebSocket, StreamableHTTP, SSE)
* - Tool discovery and caching
* - Tool execution with abort support
* - Connection state management
*/
import { Client } from '@modelcontextprotocol/sdk/client';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
MCPServerConnectionConfig,
ToolCallParams,
ToolExecutionResult
} from '$lib/types/mcp';
import { MCPError } from '$lib/errors';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
// Type for tool call result content item
interface ToolResultContentItem {
type: string;
text?: string;
data?: string;
mimeType?: string;
resource?: { text?: string; blob?: string; uri?: string };
}
// Type for tool call result
interface ToolCallResult {
content?: ToolResultContentItem[];
isError?: boolean;
_meta?: Record<string, unknown>;
}
/**
* Wraps the MCP SDK Client and provides a clean interface for tool operations.
*/
export class MCPServerConnection {
private client: Client;
private transport: Transport | null = null;
private _tools: Tool[] = [];
private _isConnected = false;
private _lastError: Error | null = null;
readonly serverName: string;
readonly config: MCPServerConnectionConfig;
constructor(config: MCPServerConnectionConfig) {
this.serverName = config.name;
this.config = config;
const clientInfo = config.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
const capabilities = config.capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
// Create SDK Client with our host info
this.client = new Client(
{
name: clientInfo.name,
version: clientInfo.version ?? '1.0.0'
},
{ capabilities }
);
}
// ─────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────
async connect(): Promise<void> {
if (this._isConnected) {
console.log(`[MCP][${this.serverName}] Already connected`);
return;
}
try {
console.log(`[MCP][${this.serverName}] Creating transport...`);
this.transport = await this.createTransport();
console.log(`[MCP][${this.serverName}] Connecting to server...`);
// SDK Client.connect() performs:
// 1. initialize request → server
// 2. Receives server capabilities
// 3. Sends initialized notification
await this.client.connect(this.transport);
console.log(`[MCP][${this.serverName}] Connected, listing tools...`);
await this.refreshTools();
this._isConnected = true;
this._lastError = null;
console.log(
`[MCP][${this.serverName}] Initialization complete with ${this._tools.length} tools`
);
} catch (error) {
this._lastError = error instanceof Error ? error : new Error(String(error));
console.error(`[MCP][${this.serverName}] Connection failed:`, error);
throw error;
}
}
async disconnect(): Promise<void> {
if (!this._isConnected) {
return;
}
console.log(`[MCP][${this.serverName}] Disconnecting...`);
try {
await this.client.close();
} catch (error) {
console.warn(`[MCP][${this.serverName}] Error during disconnect:`, error);
}
this._isConnected = false;
this._tools = [];
this.transport = null;
}
private async createTransport(): Promise<Transport> {
const serverConfig = this.config.server;
if (!serverConfig.url) {
throw new Error('MCP server configuration is missing url');
}
const url = new URL(serverConfig.url);
const requestInit: RequestInit = {};
if (serverConfig.headers) {
requestInit.headers = serverConfig.headers;
}
if (serverConfig.credentials) {
requestInit.credentials = serverConfig.credentials;
}
if (serverConfig.transport === 'websocket') {
console.log(`[MCP][${this.serverName}] Using WebSocket transport...`);
return new WebSocketClientTransport(url);
}
// Try StreamableHTTP first (modern), fall back to SSE (legacy)
try {
console.log(`[MCP][${this.serverName}] Trying StreamableHTTP transport...`);
const transport = new StreamableHTTPClientTransport(url, {
requestInit,
sessionId: serverConfig.sessionId
});
return transport;
} catch (httpError) {
console.warn(
`[MCP][${this.serverName}] StreamableHTTP failed, trying SSE transport...`,
httpError
);
try {
const transport = new SSEClientTransport(url, {
requestInit
});
return transport;
} catch (sseError) {
const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
const sseMsg = sseError instanceof Error ? sseError.message : String(sseError);
throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`);
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// Tool Discovery
// ─────────────────────────────────────────────────────────────────────────
private async refreshTools(): Promise<void> {
try {
const toolsResult = await this.client.listTools();
this._tools = toolsResult.tools ?? [];
} catch (error) {
console.warn(`[MCP][${this.serverName}] Failed to list tools:`, error);
this._tools = [];
}
}
get tools(): Tool[] {
return this._tools;
}
get toolNames(): string[] {
return this._tools.map((t) => t.name);
}
// ─────────────────────────────────────────────────────────────────────────
// Tool Execution
// ─────────────────────────────────────────────────────────────────────────
async callTool(params: ToolCallParams, signal?: AbortSignal): Promise<ToolExecutionResult> {
if (!this._isConnected) {
throw new MCPError(`Server ${this.serverName} is not connected`, -32000);
}
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
try {
const result = await this.client.callTool(
{ name: params.name, arguments: params.arguments },
undefined,
{ signal }
);
return {
content: this.formatToolResult(result as ToolCallResult),
isError: (result as ToolCallResult).isError ?? false
};
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new MCPError(`Tool execution failed: ${message}`, -32603);
}
}
// ─────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────
get isConnected(): boolean {
return this._isConnected;
}
get lastError(): Error | null {
return this._lastError;
}
// ─────────────────────────────────────────────────────────────────────────
// Formatting
// ─────────────────────────────────────────────────────────────────────────
private formatToolResult(result: ToolCallResult): string {
const content = result.content;
if (Array.isArray(content)) {
return content
.map((item) => this.formatSingleContent(item))
.filter(Boolean)
.join('\n');
}
return '';
}
private formatSingleContent(content: ToolResultContentItem): string {
if (content.type === 'text' && content.text) {
return content.text;
}
if (content.type === 'image' && content.data) {
return `data:${content.mimeType ?? 'image/png'};base64,${content.data}`;
}
if (content.type === 'resource' && content.resource) {
const resource = content.resource;
if (resource.text) {
return resource.text;
}
if (resource.blob) {
return resource.blob;
}
return JSON.stringify(resource);
}
// audio type
if (content.data && content.mimeType) {
return `data:${content.mimeType};base64,${content.data}`;
}
return JSON.stringify(content);
}
}

View File

@ -34,9 +34,13 @@ import { AttachmentType } from '$lib/enums';
* - Request lifecycle management (abort via AbortSignal)
*/
export class ChatService {
// ─────────────────────────────────────────────────────────────────────────────
// Messaging
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Messaging
*
*
*/
/**
* Sends a chat completion request to the llama.cpp server.
@ -63,6 +67,8 @@ export class ChatService {
onToolCallChunk,
onModel,
onTimings,
// Tools for function calling
tools,
// Generation parameters
temperature,
max_tokens,
@ -116,10 +122,13 @@ export class ChatService {
const requestBody: ApiChatCompletionRequest = {
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
role: msg.role,
content: msg.content
content: msg.content,
tool_calls: msg.tool_calls,
tool_call_id: msg.tool_call_id
})),
stream,
return_progress: stream ? true : undefined
return_progress: stream ? true : undefined,
tools: tools && tools.length > 0 ? tools : undefined
};
// Include model in request if provided (required in ROUTER mode)
@ -247,9 +256,13 @@ export class ChatService {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Streaming
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Streaming
*
*
*/
/**
* Handles streaming response from the chat completion API
@ -309,6 +322,8 @@ export class ChatService {
return;
}
console.log('[ChatService] Tool call delta received:', JSON.stringify(toolCalls));
aggregatedToolCalls = ChatService.mergeToolCallDeltas(
aggregatedToolCalls,
toolCalls,
@ -323,6 +338,8 @@ export class ChatService {
const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
if (!serializedToolCalls) {
return;
}
@ -563,9 +580,13 @@ export class ChatService {
return result;
}
// ─────────────────────────────────────────────────────────────────────────────
// Conversion
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Conversion
*
*
*/
/**
* Converts a database message with attachments to API chat message format.
@ -677,9 +698,13 @@ export class ChatService {
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Utilities
*
*
*/
/**
* Parses error response and creates appropriate error with context information

View File

@ -66,9 +66,13 @@ import { v4 as uuid } from 'uuid';
* `currNode` tracks the currently active branch endpoint.
*/
export class DatabaseService {
// ─────────────────────────────────────────────────────────────────────────────
// Conversations
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Conversations
*
*
*/
/**
* Creates a new conversation.
@ -88,9 +92,13 @@ export class DatabaseService {
return conversation;
}
// ─────────────────────────────────────────────────────────────────────────────
// Messages
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Messages
*
*
*/
/**
* Creates a new message branch by adding a message and updating parent/child relationships.
@ -328,9 +336,13 @@ export class DatabaseService {
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Navigation
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Navigation
*
*
*/
/**
* Updates the conversation's current node (active branch).
@ -359,9 +371,13 @@ export class DatabaseService {
await db.messages.update(id, updates);
}
// ─────────────────────────────────────────────────────────────────────────────
// Import
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Import
*
*
*/
/**
* Imports multiple conversations and their messages.

View File

@ -1,5 +1,6 @@
export { ChatService } from './chat';
export { DatabaseService } from './database';
export { ModelsService } from './models';
export { PropsService } from './props';
export { ParameterSyncService } from './parameter-sync';
export { ChatService } from './chat.service';
export { DatabaseService } from './database.service';
export { ModelsService } from './models.service';
export { PropsService } from './props.service';
export { ParameterSyncService } from './parameter-sync.service';
export { MCPService, type MCPConnection } from './mcp.service';

View File

@ -0,0 +1,233 @@
/**
* MCPService - Stateless MCP Protocol Communication Layer
*
* Low-level MCP operations:
* - Transport creation (WebSocket, StreamableHTTP, SSE)
* - Server connection/disconnection
* - Tool discovery (listTools)
* - Tool execution (callTool)
*
* NO business logic, NO state management, NO orchestration.
* This is the protocol layer - pure MCP SDK operations.
*
* @see MCPClient in clients/mcp/ for business logic facade
*/
import { Client } from '@modelcontextprotocol/sdk/client';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
MCPServerConfig,
ToolCallParams,
ToolExecutionResult,
Implementation,
ClientCapabilities
} from '$lib/types/mcp';
import { MCPError } from '$lib/errors';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
/**
* Represents an active MCP server connection.
* Returned by MCPService.connect() and used for subsequent operations.
*/
export interface MCPConnection {
/** MCP SDK Client instance */
client: Client;
/** Active transport */
transport: Transport;
/** Discovered tools from this server */
tools: Tool[];
/** Server identifier */
serverName: string;
}
interface ToolResultContentItem {
type: string;
text?: string;
data?: string;
mimeType?: string;
resource?: { text?: string; blob?: string; uri?: string };
}
interface ToolCallResult {
content?: ToolResultContentItem[];
isError?: boolean;
_meta?: Record<string, unknown>;
}
export class MCPService {
/**
* Create transport based on server configuration.
* Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports.
*/
static createTransport(config: MCPServerConfig): Transport {
if (!config.url) {
throw new Error('MCP server configuration is missing url');
}
const url = new URL(config.url);
const requestInit: RequestInit = {};
if (config.headers) {
requestInit.headers = config.headers;
}
if (config.credentials) {
requestInit.credentials = config.credentials;
}
if (config.transport === 'websocket') {
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
return new WebSocketClientTransport(url);
}
try {
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
return new StreamableHTTPClientTransport(url, {
requestInit,
sessionId: config.sessionId
});
} catch (httpError) {
console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError);
try {
return new SSEClientTransport(url, { requestInit });
} catch (sseError) {
const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
const sseMsg = sseError instanceof Error ? sseError.message : String(sseError);
throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`);
}
}
}
/**
* Connect to a single MCP server.
* Returns connection object with client, transport, and discovered tools.
*/
static async connect(
serverName: string,
serverConfig: MCPServerConfig,
clientInfo?: Implementation,
capabilities?: ClientCapabilities
): Promise<MCPConnection> {
const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
const effectiveCapabilities = capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
console.log(`[MCPService][${serverName}] Creating transport...`);
const transport = this.createTransport(serverConfig);
const client = new Client(
{
name: effectiveClientInfo.name,
version: effectiveClientInfo.version ?? '1.0.0'
},
{ capabilities: effectiveCapabilities }
);
console.log(`[MCPService][${serverName}] Connecting to server...`);
await client.connect(transport);
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
const tools = await this.listTools({ client, transport, tools: [], serverName });
console.log(`[MCPService][${serverName}] Initialization complete with ${tools.length} tools`);
return {
client,
transport,
tools,
serverName
};
}
/**
* Disconnect from a server.
*/
static async disconnect(connection: MCPConnection): Promise<void> {
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
try {
await connection.client.close();
} catch (error) {
console.warn(`[MCPService][${connection.serverName}] Error during disconnect:`, error);
}
}
/**
* List tools from a connection.
*/
static async listTools(connection: MCPConnection): Promise<Tool[]> {
try {
const result = await connection.client.listTools();
return result.tools ?? [];
} catch (error) {
console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error);
return [];
}
}
/**
* Execute a tool call on a connection.
*/
static async callTool(
connection: MCPConnection,
params: ToolCallParams,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
try {
const result = await connection.client.callTool(
{ name: params.name, arguments: params.arguments },
undefined,
{ signal }
);
return {
content: this.formatToolResult(result as ToolCallResult),
isError: (result as ToolCallResult).isError ?? false
};
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new MCPError(`Tool execution failed: ${message}`, -32603);
}
}
/**
* Format tool result content to string.
*/
private static formatToolResult(result: ToolCallResult): string {
const content = result.content;
if (!Array.isArray(content)) return '';
return content
.map((item) => this.formatSingleContent(item))
.filter(Boolean)
.join('\n');
}
private static formatSingleContent(content: ToolResultContentItem): string {
if (content.type === 'text' && content.text) {
return content.text;
}
if (content.type === 'image' && content.data) {
return `data:${content.mimeType ?? 'image/png'};base64,${content.data}`;
}
if (content.type === 'resource' && content.resource) {
const resource = content.resource;
if (resource.text) return resource.text;
if (resource.blob) return resource.blob;
return JSON.stringify(resource);
}
if (content.data && content.mimeType) {
return `data:${content.mimeType};base64,${content.data}`;
}
return JSON.stringify(content);
}
}

View File

@ -18,9 +18,13 @@ import { getJsonHeaders } from '$lib/utils';
* - modelsStore: Primary consumer for model state management
*/
export class ModelsService {
// ─────────────────────────────────────────────────────────────────────────────
// Listing
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Listing
*
*
*/
/**
* Fetch list of models from OpenAI-compatible endpoint
@ -54,9 +58,13 @@ export class ModelsService {
return response.json() as Promise<ApiRouterModelsListResponse>;
}
// ─────────────────────────────────────────────────────────────────────────────
// Load/Unload
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Load/Unload
*
*
*/
/**
* Load a model (ROUTER mode)
@ -104,9 +112,13 @@ export class ModelsService {
return response.json() as Promise<ApiRouterModelsUnloadResponse>;
}
// ─────────────────────────────────────────────────────────────────────────────
// Status
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Status
*
*
*/
/**
* Check if a model is loaded based on its metadata

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { ParameterSyncService } from './parameter-sync';
import { ParameterSyncService } from './parameter-sync.service';
describe('ParameterSyncService', () => {
describe('roundFloatingPoint', () => {

View File

@ -100,9 +100,13 @@ export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
];
export class ParameterSyncService {
// ─────────────────────────────────────────────────────────────────────────────
// Extraction
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Extraction
*
*
*/
/**
* Round floating-point numbers to avoid JavaScript precision issues
@ -153,9 +157,13 @@ export class ParameterSyncService {
return extracted;
}
// ─────────────────────────────────────────────────────────────────────────────
// Merging
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Merging
*
*
*/
/**
* Merge server defaults with current user settings
@ -178,9 +186,13 @@ export class ParameterSyncService {
return merged;
}
// ─────────────────────────────────────────────────────────────────────────────
// Info
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Info
*
*
*/
/**
* Get parameter information including source and values
@ -238,9 +250,13 @@ export class ParameterSyncService {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Diff
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Diff
*
*
*/
/**
* Create a diff between current settings and server defaults

View File

@ -15,9 +15,13 @@ import { getAuthHeaders } from '$lib/utils';
* - serverStore: Primary consumer for server state management
*/
export class PropsService {
// ─────────────────────────────────────────────────────────────────────────────
// Fetching
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Fetching
*
*
*/
/**
* Fetches server properties from the /props endpoint

View File

@ -1,100 +1,73 @@
/**
* agenticStore - Orchestration of the agentic loop with MCP tools
* agenticStore - Reactive State Store for Agentic Loop
*
* This store is responsible for:
* - Managing the agentic loop lifecycle
* - Coordinating between LLM and MCP tool execution
* - Tracking session state (messages, turns, tool calls)
* - Emitting streaming content and tool results
* This store contains ONLY reactive state ($state).
* All business logic is delegated to AgenticClient.
*
* **Architecture & Relationships:**
* - **mcpStore**: Provides MCP host manager for tool execution
* - **chatStore**: Triggers agentic flow and receives streaming updates
* - **OpenAISseClient**: LLM communication for streaming responses
* - **settingsStore**: Provides agentic configuration (maxTurns, etc.)
* - **AgenticClient**: Business logic facade (loop orchestration, tool execution)
* - **MCPClient**: Tool execution via MCP servers
* - **agenticStore** (this): Reactive state for UI components
*
* **Key Features:**
* - Stateful session management (unlike stateless ChatService)
* - Multi-turn tool call orchestration
* - Automatic routing of tool calls to appropriate MCP servers
* - Raw LLM output streaming (UI formatting is separate concern)
* **Responsibilities:**
* - Hold reactive state for UI binding (isRunning, currentTurn, etc.)
* - Provide getters for computed values
* - Expose setters for AgenticClient to update state
* - Forward method calls to AgenticClient
*
* @see AgenticClient in clients/agentic/ for business logic
* @see MCPClient in clients/mcp/ for tool execution
*/
import { mcpStore } from '$lib/stores/mcp.svelte';
import { OpenAISseClient, type OpenAISseTurnResult } from '$lib/clients/openai-sse';
import type {
AgenticMessage,
AgenticChatCompletionRequest,
AgenticToolCallList
} from '$lib/types/agentic';
import { toAgenticMessages } from '$lib/utils';
import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api';
import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat';
import type { MCPToolCall } from '$lib/types/mcp';
import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '$lib/types/database';
import { getAgenticConfig } from '$lib/config/agentic';
import { config } from '$lib/stores/settings.svelte';
import { getAuthHeaders } from '$lib/utils';
import { ChatService } from '$lib/services';
import { browser } from '$app/environment';
import type { AgenticFlowParams, AgenticFlowResult } from '$lib/clients';
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export interface AgenticFlowCallbacks {
onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (serializedToolCalls: string) => void;
onModel?: (model: string) => void;
onComplete?: (
content: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCalls?: string
) => void;
onError?: (error: Error) => void;
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
}
export interface AgenticFlowOptions {
stream?: boolean;
model?: string;
temperature?: number;
max_tokens?: number;
[key: string]: unknown;
}
export interface AgenticFlowParams {
messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[];
options?: AgenticFlowOptions;
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
/** Per-chat MCP server overrides */
perChatOverrides?: McpServerOverride[];
}
export interface AgenticFlowResult {
handled: boolean;
error?: Error;
}
// ─────────────────────────────────────────────────────────────────────────────
// Agentic Store
// ─────────────────────────────────────────────────────────────────────────────
export type {
AgenticFlowCallbacks,
AgenticFlowOptions,
AgenticFlowParams,
AgenticFlowResult
} from '$lib/clients';
class AgenticStore {
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
private _isRunning = $state(false);
private _currentTurn = $state(0);
private _totalToolCalls = $state(0);
private _lastError = $state<Error | null>(null);
private _streamingToolCall = $state<{ name: string; arguments: string } | null>(null);
// ─────────────────────────────────────────────────────────────────────────────
// Getters
// ─────────────────────────────────────────────────────────────────────────────
/** Reference to the client (lazy loaded to avoid circular dependency) */
private _client: typeof import('$lib/clients/agentic.client').agenticClient | null = null;
private get client() {
return this._client;
}
/** Check if store is ready (client initialized) */
get isReady(): boolean {
return this._client !== null;
}
/**
* Initialize the store by wiring up to the client.
* Must be called once after app startup.
*/
async init(): Promise<void> {
if (!browser) return;
if (this._client) return; // Already initialized
const { agenticClient } = await import('$lib/clients/agentic.client');
this._client = agenticClient;
agenticClient.setStoreCallbacks({
setRunning: (running) => (this._isRunning = running),
setCurrentTurn: (turn) => (this._currentTurn = turn),
setTotalToolCalls: (count) => (this._totalToolCalls = count),
setLastError: (error) => (this._lastError = error),
setStreamingToolCall: (tc) => (this._streamingToolCall = tc),
clearStreamingToolCall: () => (this._streamingToolCall = null)
});
}
get isRunning(): boolean {
return this._isRunning;
@ -112,375 +85,37 @@ class AgenticStore {
return this._lastError;
}
// ─────────────────────────────────────────────────────────────────────────────
// Main Agentic Flow
// ─────────────────────────────────────────────────────────────────────────────
get streamingToolCall(): { name: string; arguments: string } | null {
return this._streamingToolCall;
}
/**
* Run the agentic orchestration loop with MCP tools.
*
* This is the main entry point called by chatStore when agentic mode is enabled.
* It coordinates:
* 1. Initial LLM request with available tools
* 2. Tool call detection and execution via MCP
* 3. Multi-turn loop until completion or turn limit
*
* @returns AgenticFlowResult indicating if the flow handled the request
* Delegates to AgenticClient.
*/
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
const { messages, options = {}, callbacks, signal, perChatOverrides } = params;
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } =
callbacks;
// Get agentic configuration (considering per-chat MCP overrides)
const agenticConfig = getAgenticConfig(config(), perChatOverrides);
if (!agenticConfig.enabled) {
return { handled: false };
}
// Ensure MCP is initialized with per-chat overrides
const hostManager = await mcpStore.ensureInitialized(perChatOverrides);
if (!hostManager) {
console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
return { handled: false };
}
const tools = mcpStore.getToolDefinitions();
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`);
// Normalize messages to API format
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) => {
// Filter out empty system messages
if (msg.role === 'system') {
const content = typeof msg.content === 'string' ? msg.content : '';
return content.trim().length > 0;
}
return true;
});
// Reset state
this._isRunning = true;
this._currentTurn = 0;
this._totalToolCalls = 0;
this._lastError = null;
try {
await this.executeAgenticLoop({
messages: normalizedMessages,
options,
tools,
agenticConfig,
callbacks: {
onChunk,
onReasoningChunk,
onToolCallChunk,
onModel,
onComplete,
onError,
onTimings
},
signal
});
return { handled: true };
} catch (error) {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this._lastError = normalizedError;
onError?.(normalizedError);
return { handled: true, error: normalizedError };
} finally {
this._isRunning = false;
// Lazy Disconnect: Close MCP connections after agentic flow completes
// This prevents continuous keepalive/heartbeat polling when tools are not in use
await mcpStore.shutdown().catch((err) => {
console.warn('[AgenticStore] Failed to shutdown MCP after flow:', err);
});
console.log('[AgenticStore] MCP connections closed (lazy disconnect)');
if (!this.client) {
throw new Error('AgenticStore not initialized. Call init() first.');
}
return this.client.runAgenticFlow(params);
}
// ─────────────────────────────────────────────────────────────────────────────
// Private: Agentic Loop Implementation
// ─────────────────────────────────────────────────────────────────────────────
private async executeAgenticLoop(params: {
messages: ApiChatMessageData[];
options: AgenticFlowOptions;
tools: ReturnType<typeof mcpStore.getToolDefinitions>;
agenticConfig: ReturnType<typeof getAgenticConfig>;
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
}): Promise<void> {
const { messages, options, tools, agenticConfig, callbacks, signal } = params;
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onTimings } =
callbacks;
// Set up LLM client
const llmClient = new OpenAISseClient({
url: './v1/chat/completions',
buildHeaders: () => getAuthHeaders()
});
// Prepare session state
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
const allToolCalls: ApiChatCompletionToolCall[] = [];
let capturedTimings: ChatMessageTimings | undefined;
// Build base request from options (messages change per turn)
const requestBase: AgenticChatCompletionRequest = {
...options,
stream: true,
messages: []
};
const maxTurns = agenticConfig.maxTurns;
const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
// Run agentic loop
for (let turn = 0; turn < maxTurns; turn++) {
this._currentTurn = turn + 1;
if (signal?.aborted) {
onComplete?.('', undefined, capturedTimings, undefined);
return;
}
// Build LLM request for this turn
const llmRequest: AgenticChatCompletionRequest = {
...requestBase,
messages: sessionMessages,
tools: tools.length > 0 ? tools : undefined
};
// Filter reasoning content after first turn if configured
const shouldFilterReasoning = agenticConfig.filterReasoningAfterFirstTurn && turn > 0;
// Stream from LLM
let turnResult: OpenAISseTurnResult;
try {
turnResult = await llmClient.stream(
llmRequest,
{
onChunk,
onReasoningChunk: shouldFilterReasoning ? undefined : onReasoningChunk,
onModel,
onFirstValidChunk: undefined,
onProcessingUpdate: (timings, progress) => {
onTimings?.(timings, progress);
if (timings) capturedTimings = timings;
}
},
signal
);
} catch (error) {
if (signal?.aborted) {
onComplete?.('', undefined, capturedTimings, 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, capturedTimings, undefined);
throw normalizedError;
}
// Check if we should stop (no tool calls or finish reason isn't tool_calls)
if (
turnResult.toolCalls.length === 0 ||
(turnResult.finishReason && turnResult.finishReason !== 'tool_calls')
) {
onComplete?.('', undefined, capturedTimings, undefined);
return;
}
// Normalize and validate tool calls
const normalizedCalls = this.normalizeToolCalls(turnResult.toolCalls);
if (normalizedCalls.length === 0) {
onComplete?.('', undefined, capturedTimings, undefined);
return;
}
// Accumulate tool calls
for (const call of normalizedCalls) {
allToolCalls.push({
id: call.id,
type: call.type,
function: call.function ? { ...call.function } : undefined
});
}
this._totalToolCalls = allToolCalls.length;
onToolCallChunk?.(JSON.stringify(allToolCalls));
// Add assistant message with tool calls to session
sessionMessages.push({
role: 'assistant',
content: turnResult.content || undefined,
tool_calls: normalizedCalls
});
// Execute each tool call via MCP
for (const toolCall of normalizedCalls) {
if (signal?.aborted) {
onComplete?.('', undefined, capturedTimings, undefined);
return;
}
// Emit tool call start (shows "pending" state in UI)
this.emitToolCallStart(toolCall, onChunk);
const mcpCall: MCPToolCall = {
id: toolCall.id,
function: {
name: toolCall.function.name,
arguments: toolCall.function.arguments
}
};
let result: string;
try {
const executionResult = await mcpStore.executeTool(mcpCall, signal);
result = executionResult.content;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
onComplete?.('', undefined, capturedTimings, undefined);
return;
}
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
}
if (signal?.aborted) {
onComplete?.('', undefined, capturedTimings, undefined);
return;
}
// Emit tool result and end marker
this.emitToolCallResult(result, maxToolPreviewLines, onChunk);
// Add tool result to session (sanitize base64 images for context)
const contextValue = this.isBase64Image(result) ? '[Image displayed to user]' : result;
sessionMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: contextValue
});
}
}
// Turn limit reached
onChunk?.('\n\n```\nTurn limit reached\n```\n');
onComplete?.('', undefined, capturedTimings, undefined);
}
// ─────────────────────────────────────────────────────────────────────────────
// Private: Helper Methods
// ─────────────────────────────────────────────────────────────────────────────
/**
* Normalize tool calls from LLM response
*/
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
if (!toolCalls) return [];
return toolCalls.map((call, index) => ({
id: call?.id ?? `tool_${index}`,
type: (call?.type as 'function') ?? 'function',
function: {
name: call?.function?.name ?? '',
arguments: call?.function?.arguments ?? ''
}
}));
}
/**
* Emit tool call start marker (shows "pending" state in UI).
*/
private emitToolCallStart(
toolCall: AgenticToolCallList[number],
emit?: (chunk: string) => void
): void {
if (!emit) return;
const toolName = toolCall.function.name;
const toolArgs = toolCall.function.arguments;
// Base64 encode args to avoid conflicts with markdown/HTML parsing
const toolArgsBase64 = btoa(unescape(encodeURIComponent(toolArgs)));
let output = `\n\n<<<AGENTIC_TOOL_CALL_START>>>`;
output += `\n<<<TOOL_NAME:${toolName}>>>`;
output += `\n<<<TOOL_ARGS_BASE64:${toolArgsBase64}>>>`;
emit(output);
}
/**
* Emit tool call result and end marker.
*/
private emitToolCallResult(
result: string,
maxLines: number,
emit?: (chunk: string) => void
): void {
if (!emit) return;
let output = '';
if (this.isBase64Image(result)) {
output += `\n![tool-result](${result.trim()})`;
} else {
// Don't wrap in code fences - result may already be markdown with its own code blocks
const lines = result.split('\n');
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
output += `\n${trimmedLines.join('\n')}`;
}
output += `\n<<<AGENTIC_TOOL_CALL_END>>>\n`;
emit(output);
}
/**
* Check if content is a base64 image
*/
private isBase64Image(content: string): boolean {
const trimmed = content.trim();
if (!trimmed.startsWith('data:image/')) return false;
const match = trimmed.match(/^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+=*)$/);
if (!match) return false;
const base64Payload = match[2];
return base64Payload.length > 0 && base64Payload.length % 4 === 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Clear error state
*/
clearError(): void {
this._lastError = null;
if (!this.client) return;
this.client.clearError();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Singleton Instance & Exports
// ─────────────────────────────────────────────────────────────────────────────
export const agenticStore = new AgenticStore();
// Reactive exports for components
// Auto-initialize in browser
if (browser) {
agenticStore.init();
}
export function agenticIsRunning() {
return agenticStore.isRunning;
}
@ -496,3 +131,7 @@ export function agenticTotalToolCalls() {
export function agenticLastError() {
return agenticStore.lastError;
}
export function agenticStreamingToolCall() {
return agenticStore.streamingToolCall;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,56 +1,29 @@
/**
* conversationsStore - Reactive State Store for Conversations
*
* This store contains ONLY reactive state ($state, $derived).
* All business logic is delegated to ConversationsClient.
*
* **Architecture & Relationships:**
* - **ConversationsClient**: Business logic facade (CRUD, navigation, import/export)
* - **DatabaseService**: Stateless IndexedDB layer
* - **conversationsStore** (this): Reactive state for UI components
*
* **Responsibilities:**
* - Hold reactive state for UI binding
* - Provide getters for computed values
* - Expose setters for ConversationsClient to update state
* - Forward method calls to ConversationsClient
*
* @see ConversationsClient in clients/ for business logic
* @see DatabaseService in services/database.ts for IndexedDB operations
*/
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
import { AttachmentType } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
/**
* conversationsStore - Persistent conversation data and lifecycle management
*
* **Terminology - Chat vs Conversation:**
* - **Chat**: The active interaction space with the Chat Completions API. Represents the
* real-time streaming session, loading states, and UI visualization of AI communication.
* Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions.
* - **Conversation**: The persistent database entity storing all messages and metadata.
* A "conversation" survives across sessions, page reloads, and browser restarts.
* It contains the complete message history, branching structure, and conversation metadata.
*
* This store manages all conversation-level data and operations including creation, loading,
* deletion, and navigation. It maintains the list of conversations and the currently active
* conversation with its message history, providing reactive state for UI components.
*
* **Architecture & Relationships:**
* - **conversationsStore** (this class): Persistent conversation data management
* - Manages conversation list and active conversation state
* - Handles conversation CRUD operations via DatabaseService
* - Maintains active message array for current conversation
* - Coordinates branching navigation (currNode tracking)
*
* - **chatStore**: Uses conversation data as context for active AI streaming
* - **DatabaseService**: Low-level IndexedDB storage for conversations and messages
*
* **Key Features:**
* - **Conversation Lifecycle**: Create, load, update, delete conversations
* - **Message Management**: Active message array with branching support
* - **Import/Export**: JSON-based conversation backup and restore
* - **Branch Navigation**: Navigate between message tree branches
* - **Title Management**: Auto-update titles with confirmation dialogs
* - **Reactive State**: Svelte 5 runes for automatic UI updates
*
* **State Properties:**
* - `conversations`: All conversations sorted by last modified
* - `activeConversation`: Currently viewed conversation
* - `activeMessages`: Messages in current conversation path
* - `isInitialized`: Store initialization status
*/
class ConversationsStore {
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
/** List of all conversations */
conversations = $state<DatabaseConversation[]>([]);
@ -69,22 +42,57 @@ class ConversationsStore {
/** Callback for title update confirmation dialog */
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
// ─────────────────────────────────────────────────────────────────────────────
// Modalities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Modalities used in the active conversation.
* Computed from attachments in activeMessages.
* Used to filter available models - models must support all used modalities.
*/
usedModalities: ModelModalities = $derived.by(() => {
return this.calculateModalitiesFromMessages(this.activeMessages);
});
/** 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;
}
/**
* Initialize the store by wiring up to the client.
* Must be called once after app startup.
*/
async init(): Promise<void> {
if (!browser) return;
if (this._client) return;
const { conversationsClient } = await import('$lib/clients/conversations.client');
this._client = conversationsClient;
conversationsClient.setStoreCallbacks({
getConversations: () => this.conversations,
setConversations: (conversations) => (this.conversations = conversations),
getActiveConversation: () => this.activeConversation,
setActiveConversation: (conversation) => (this.activeConversation = conversation),
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();
}
/**
* Calculate modalities from a list of messages.
* Helper method used by both usedModalities and getModalitiesUpToMessage.
*/
private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
const modalities: ModelModalities = { vision: false, audio: false };
@ -119,7 +127,6 @@ class ConversationsStore {
/**
* Get modalities used in messages BEFORE the specified message.
* Used for regeneration - only consider context that was available when generating this message.
*/
getModalitiesUpToMessage(messageId: string): ModelModalities {
const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
@ -132,596 +139,8 @@ class ConversationsStore {
return this.calculateModalitiesFromMessages(messagesBefore);
}
constructor() {
if (browser) {
this.initialize();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
/**
* Initializes the conversations store by loading conversations from the database
*/
async initialize(): Promise<void> {
try {
await this.loadConversations();
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize conversations store:', error);
}
}
/**
* Loads all conversations from the database
*/
async loadConversations(): Promise<void> {
this.conversations = await DatabaseService.getAllConversations();
}
// ─────────────────────────────────────────────────────────────────────────────
// Conversation CRUD
// ─────────────────────────────────────────────────────────────────────────────
/**
* 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);
// Apply any pending MCP server overrides to the new conversation
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 = []; // Clear pending overrides
}
this.conversations.unshift(conversation);
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> {
try {
const conversation = await DatabaseService.getConversation(convId);
if (!conversation) {
return false;
}
// Clear pending overrides when switching to an existing conversation
this.pendingMcpServerOverrides = [];
this.activeConversation = conversation;
if (conversation.currNode) {
const allMessages = await DatabaseService.getConversationMessages(convId);
this.activeMessages = filterByLeafNodeId(
allMessages,
conversation.currNode,
false
) as DatabaseMessage[];
} else {
this.activeMessages = await DatabaseService.getConversationMessages(convId);
}
return true;
} catch (error) {
console.error('Failed to load conversation:', error);
return false;
}
}
/**
* Clears the active conversation and messages
* Used when navigating away from chat or starting fresh
*/
clearActiveConversation(): void {
this.activeConversation = null;
this.activeMessages = [];
// Active processing conversation is now managed by chatStore
}
// ─────────────────────────────────────────────────────────────────────────────
// Message Management
// ─────────────────────────────────────────────────────────────────────────────
/**
* Refreshes active messages based on currNode after branch navigation
*/
async refreshActiveMessages(): Promise<void> {
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.length = 0;
this.activeMessages.push(...currentPath);
}
/**
* 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 convIndex = this.conversations.findIndex((c) => c.id === convId);
if (convIndex !== -1) {
this.conversations[convIndex].name = name;
}
if (this.activeConversation?.id === convId) {
this.activeConversation.name = 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
* @param onConfirmationNeeded - Callback when user confirmation is needed
* @returns True if title was updated, false if cancelled
*/
async updateConversationTitleWithConfirmation(
convId: string,
newTitle: string,
onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
): Promise<boolean> {
try {
const currentConfig = config();
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;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Navigation
// ─────────────────────────────────────────────────────────────────────────────
/**
* 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.currNode = nodeId;
}
/**
* 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.unshift(updatedConv);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// MCP Server Per-Chat 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
);
}
// Fall back to pending overrides if no active conversation
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
}
/**
* Checks if an MCP server is enabled for the active conversation.
* Per-chat override takes precedence over global setting.
* @param serverId - The server ID to check
* @param globalEnabled - The global enabled state from settings
* @returns True if server is enabled for this conversation
*/
isMcpServerEnabledForChat(serverId: string, globalEnabled: boolean): boolean {
const override = this.getMcpServerOverride(serverId);
return override !== undefined ? override.enabled : globalEnabled;
}
/**
* Sets or removes MCP server override for the active conversation.
* If no conversation exists, stores as pending override (applied when conversation is created).
* @param serverId - The server ID to override
* @param enabled - The enabled state, or undefined to remove override (use global)
*/
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
// If no active conversation, store as pending override
if (!this.activeConversation) {
this.setPendingMcpServerOverride(serverId, enabled);
return;
}
// 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) {
// Remove override - use global setting
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
} else {
// Set or update override
const existingIndex = currentOverrides.findIndex(
(o: McpServerOverride) => o.serverId === serverId
);
if (existingIndex >= 0) {
newOverrides = [...currentOverrides];
newOverrides[existingIndex] = { serverId, enabled };
} else {
newOverrides = [...currentOverrides, { serverId, enabled }];
}
}
// Update in database (plain objects, not proxies)
await DatabaseService.updateConversation(this.activeConversation.id, {
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
});
// Update local state
this.activeConversation.mcpServerOverrides = newOverrides.length > 0 ? newOverrides : undefined;
// Also update in conversations list
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (convIndex !== -1) {
this.conversations[convIndex].mcpServerOverrides =
newOverrides.length > 0 ? newOverrides : undefined;
}
}
/**
* Toggles MCP server enabled state for the active conversation.
* Creates a per-chat override that differs from the global setting.
* @param serverId - The server ID to toggle
* @param globalEnabled - The global enabled state from settings
*/
async toggleMcpServerForChat(serverId: string, globalEnabled: boolean): Promise<void> {
const currentEnabled = this.isMcpServerEnabledForChat(serverId, globalEnabled);
await this.setMcpServerOverride(serverId, !currentEnabled);
}
/**
* Resets MCP server to use global setting (removes per-chat override).
* @param serverId - The server ID to reset
*/
async resetMcpServerToGlobal(serverId: string): Promise<void> {
await this.setMcpServerOverride(serverId, undefined);
}
/**
* Sets or removes a pending MCP server override (for new conversations).
* @param serverId - The server ID to override
* @param enabled - The enabled state, or undefined to remove override
*/
private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
if (enabled === undefined) {
// Remove pending override
this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter(
(o) => o.serverId !== serverId
);
} else {
// Set or update pending override
const existingIndex = this.pendingMcpServerOverrides.findIndex(
(o) => o.serverId === serverId
);
if (existingIndex >= 0) {
this.pendingMcpServerOverrides[existingIndex] = { serverId, enabled };
} else {
this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }];
}
}
}
/**
* Gets a pending MCP server override.
* @param serverId - The server ID to check
*/
private getPendingMcpServerOverride(serverId: string): McpServerOverride | undefined {
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
}
/**
* Clears all pending MCP server overrides.
*/
clearPendingMcpServerOverrides(): void {
this.pendingMcpServerOverrides = [];
}
/**
* 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 === 'user' && m.parent === rootMessage?.id
);
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
this.activeConversation.currNode = currentLeafNodeId;
await this.refreshActiveMessages();
// Only show title dialog if we're navigating between different first user message siblings
if (rootMessage && this.activeMessages.length > 0) {
const newFirstUserMessage = this.activeMessages.find(
(m) => m.role === 'user' && m.parent === rootMessage.id
);
if (
newFirstUserMessage &&
newFirstUserMessage.content.trim() &&
(!currentFirstUserMessage ||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
) {
await this.updateConversationTitleWithConfirmation(
this.activeConversation.id,
newFirstUserMessage.content.trim(),
this.titleUpdateConfirmationCallback
);
}
}
}
/**
* 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);
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> {
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');
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 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[];
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[]> {
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();
});
}
/**
* 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);
}
/**
* 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;
}
/**
* Adds a message to the active messages array
* Used by chatStore when creating new messages
* @param message - The message to add
*/
addMessageToActive(message: DatabaseMessage): void {
this.activeMessages.push(message);
@ -729,21 +148,15 @@ class ConversationsStore {
/**
* Updates a message at a specific index in active messages
* Creates a new object to trigger Svelte 5 reactivity
* @param index - The index of the message to update
* @param updates - Partial message data to update
*/
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
if (index !== -1 && this.activeMessages[index]) {
// Create new object to trigger Svelte 5 reactivity
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
}
}
/**
* Finds the index of a message in active messages
* @param messageId - The message ID to find
* @returns The index of the message, or -1 if not found
*/
findMessageIndex(messageId: string): number {
return this.activeMessages.findIndex((m) => m.id === messageId);
@ -751,7 +164,6 @@ class ConversationsStore {
/**
* Removes messages from active messages starting at an index
* @param startIndex - The index to start removing from
*/
sliceActiveMessages(startIndex: number): void {
this.activeMessages = this.activeMessages.slice(0, startIndex);
@ -759,8 +171,6 @@ class ConversationsStore {
/**
* Removes a message from active messages by index
* @param index - The index to remove
* @returns The removed message or undefined
*/
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
if (index !== -1) {
@ -769,56 +179,156 @@ class ConversationsStore {
return undefined;
}
/**
* Triggers file download in browser
* @param data - The data to download
* @param filename - Optional filename for the download
*/
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);
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Sets the callback function for title update confirmations
* @param callback - Function to call when confirmation is needed
*/
setTitleUpdateConfirmationCallback(
callback: (currentTitle: string, newTitle: string) => Promise<boolean>
): void {
this.titleUpdateConfirmationCallback = callback;
}
async initialize(): Promise<void> {
if (!this.client) return;
return this.client.initialize();
}
async loadConversations(): Promise<void> {
if (!this.client) return;
return this.client.loadConversations();
}
async createConversation(name?: string): Promise<string> {
if (!this.client) throw new Error('ConversationsStore not initialized');
return this.client.createConversation(name);
}
async loadConversation(convId: string): Promise<boolean> {
if (!this.client) return false;
return this.client.loadConversation(convId);
}
clearActiveConversation(): void {
if (!this.client) return;
this.client.clearActiveConversation();
}
async deleteConversation(convId: string): Promise<void> {
if (!this.client) return;
return this.client.deleteConversation(convId);
}
async deleteAll(): Promise<void> {
if (!this.client) return;
return this.client.deleteAll();
}
async refreshActiveMessages(): Promise<void> {
if (!this.client) return;
return this.client.refreshActiveMessages();
}
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
if (!this.client) return [];
return this.client.getConversationMessages(convId);
}
async updateConversationName(convId: string, name: string): Promise<void> {
if (!this.client) return;
return this.client.updateConversationName(convId, name);
}
async updateConversationTitleWithConfirmation(
convId: string,
newTitle: string
): Promise<boolean> {
if (!this.client) return false;
return this.client.updateConversationTitleWithConfirmation(convId, newTitle);
}
updateConversationTimestamp(): void {
if (!this.client) return;
this.client.updateConversationTimestamp();
}
async updateCurrentNode(nodeId: string): Promise<void> {
if (!this.client) return;
return this.client.updateCurrentNode(nodeId);
}
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);
}
return this.client.getMcpServerOverride(serverId);
}
isMcpServerEnabledForChat(serverId: string, globalEnabled: boolean): boolean {
if (!this.client) {
const override = this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
return override !== undefined ? override.enabled : globalEnabled;
}
return this.client.isMcpServerEnabledForChat(serverId, globalEnabled);
}
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
if (!this.client) return;
return this.client.setMcpServerOverride(serverId, enabled);
}
async toggleMcpServerForChat(serverId: string, globalEnabled: boolean): Promise<void> {
if (!this.client) return;
return this.client.toggleMcpServerForChat(serverId, globalEnabled);
}
async resetMcpServerToGlobal(serverId: string): Promise<void> {
if (!this.client) return;
return this.client.resetMcpServerToGlobal(serverId);
}
clearPendingMcpServerOverrides(): void {
if (!this.client) {
this.pendingMcpServerOverrides = [];
return;
}
this.client.clearPendingMcpServerOverrides();
}
async downloadConversation(convId: string): Promise<void> {
if (!this.client) return;
return this.client.downloadConversation(convId);
}
async exportAllConversations(): Promise<DatabaseConversation[]> {
if (!this.client) return [];
return this.client.exportAllConversations();
}
async importConversations(): Promise<DatabaseConversation[]> {
if (!this.client) return [];
return this.client.importConversations();
}
async importConversationsData(
data: ExportedConversations
): Promise<{ imported: number; skipped: number }> {
if (!this.client) return { imported: 0, skipped: 0 };
return this.client.importConversationsData(data);
}
}
export const conversationsStore = new ConversationsStore();
// Auto-initialize in browser
if (browser) {
conversationsStore.init();
}
export const conversations = () => conversationsStore.conversations;
export const activeConversation = () => conversationsStore.activeConversation;
export const activeMessages = () => conversationsStore.activeMessages;

View File

@ -1,67 +1,66 @@
import { browser } from '$app/environment';
import { MCPHostManager } from '$lib/mcp/host-manager';
import { MCPServerConnection } from '$lib/mcp/server-connection';
import type { OpenAIToolDefinition, ServerStatus, ToolExecutionResult } from '$lib/types/mcp';
import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/config/mcp';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import type { MCPToolCall } from '$lib/types/mcp';
import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { detectMcpTransportFromUrl } from '$lib/utils/mcp';
// ─────────────────────────────────────────────────────────────────────────────
// Health Check Types
// ─────────────────────────────────────────────────────────────────────────────
export type HealthCheckState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; tools: { name: string; description?: string }[] };
/**
* mcpStore - Reactive store for MCP (Model Context Protocol) host management
* mcpStore - Reactive State Store for MCP (Model Context Protocol)
*
* This store manages:
* - MCPHostManager lifecycle (initialization, shutdown)
* - Connection state tracking for multiple MCP servers
* - Aggregated tools from all connected MCP servers
* - Error handling for MCP operations
* This store contains ONLY reactive state ($state, $derived).
* All business logic is delegated to MCPClient.
*
* **Architecture & Relationships:**
* - **MCPHostManager**: Coordinates multiple MCPServerConnection instances
* - **MCPServerConnection**: Single SDK Client wrapper per server
* - **mcpStore** (this class): Reactive Svelte store for MCP state
* - **agenticStore**: Uses mcpStore for tool execution in agentic loop
* - **settingsStore**: Provides MCP server configuration
* - **MCPClient**: Business logic facade (lifecycle, tool execution, health checks)
* - **MCPService**: Stateless protocol layer (transport, connect, callTool)
* - **mcpStore** (this): Reactive state for UI components
*
* **Key Features:**
* - Reactive state with Svelte 5 runes ($state, $derived)
* - Automatic reinitialization on config changes
* - Aggregates tools from multiple servers
* - Routes tool calls to appropriate server automatically
* - Graceful error handling with fallback to standard chat
* **Responsibilities:**
* - Hold reactive state for UI binding
* - Provide getters for computed values
* - Expose setters for MCPClient to update state
* - Forward method calls to MCPClient
*
* @see MCPClient in clients/mcp/ for business logic
* @see MCPService in services/mcp.ts for protocol operations
*/
class MCPStore {
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
private _hostManager = $state<MCPHostManager | null>(null);
import { browser } from '$app/environment';
import { mcpClient, type HealthCheckState, type HealthCheckParams } from '$lib/clients';
import type {
OpenAIToolDefinition,
ServerStatus,
ToolExecutionResult,
MCPToolCall
} from '$lib/types/mcp';
import type { McpServerOverride } from '$lib/types/database';
import { buildMcpClientConfig } from '$lib/utils/mcp';
import { config } from '$lib/stores/settings.svelte';
export type { HealthCheckState };
class MCPStore {
private _isInitializing = $state(false);
private _error = $state<string | null>(null);
private _configSignature = $state<string | null>(null);
private _initPromise: Promise<MCPHostManager | undefined> | null = null;
// Health check state (in-memory only, not persisted)
private _toolCount = $state(0);
private _connectedServers = $state<string[]>([]);
private _healthChecks = $state<Record<string, HealthCheckState>>({});
// ─────────────────────────────────────────────────────────────────────────────
// Computed Getters
// ─────────────────────────────────────────────────────────────────────────────
constructor() {
if (browser) {
mcpClient.setStateChangeCallback((state) => {
if (state.isInitializing !== undefined) {
this._isInitializing = state.isInitializing;
}
if (state.error !== undefined) {
this._error = state.error;
}
if (state.toolCount !== undefined) {
this._toolCount = state.toolCount;
}
if (state.connectedServers !== undefined) {
this._connectedServers = state.connectedServers;
}
});
get hostManager(): MCPHostManager | null {
return this._hostManager;
mcpClient.setHealthCheckCallback((serverId, state) => {
this._healthChecks = { ...this._healthChecks, [serverId]: state };
});
}
}
get isInitializing(): boolean {
@ -69,13 +68,25 @@ class MCPStore {
}
get isInitialized(): boolean {
return this._hostManager?.isInitialized ?? false;
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)
*/
@ -87,245 +98,73 @@ class MCPStore {
}
/**
* Get list of available tool names (aggregated from all servers)
* Get list of available tool names
*/
get availableTools(): string[] {
return this._hostManager?.getToolNames() ?? [];
return mcpClient.getToolNames();
}
/**
* Get number of connected servers
* Ensure MCP is initialized with current config.
* @param perChatOverrides - Optional per-chat MCP server overrides
*/
get connectedServerCount(): number {
return this._hostManager?.connectedServerCount ?? 0;
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
return mcpClient.ensureInitialized(perChatOverrides);
}
/**
* Get names of connected servers
* Shutdown MCP connections and clear state
*/
get connectedServerNames(): string[] {
return this._hostManager?.connectedServerNames ?? [];
}
/**
* Get total tool count
*/
get toolCount(): number {
return this._hostManager?.toolCount ?? 0;
async shutdown(): Promise<void> {
return mcpClient.shutdown();
}
/**
* Get tool definitions for LLM (OpenAI function calling format)
*/
getToolDefinitions(): OpenAIToolDefinition[] {
return this._hostManager?.getToolDefinitionsForLLM() ?? [];
return mcpClient.getToolDefinitionsForLLM();
}
/**
* Get status of all servers
*/
getServersStatus(): ServerStatus[] {
return this._hostManager?.getServersStatus() ?? [];
}
// ─────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
/**
* Ensure MCP host manager is initialized with current config.
* Returns the host manager if successful, undefined otherwise.
* Handles config changes by reinitializing as needed.
* @param perChatOverrides - Optional per-chat MCP server overrides
*/
async ensureInitialized(
perChatOverrides?: McpServerOverride[]
): Promise<MCPHostManager | undefined> {
if (!browser) return undefined;
const mcpConfig = buildMcpClientConfig(config(), perChatOverrides);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
// No config - shutdown if needed
if (!signature) {
await this.shutdown();
return undefined;
}
// Already initialized with correct config
if (this._hostManager?.isInitialized && this._configSignature === signature) {
return this._hostManager;
}
// Init in progress with correct config - wait for it
if (this._initPromise && this._configSignature === signature) {
return this._initPromise;
}
// Config changed or first init - shutdown old manager first
if (this._hostManager || this._initPromise) {
await this.shutdown();
}
// Initialize new host manager
return this.initialize(signature, mcpConfig!);
return mcpClient.getServersStatus();
}
/**
* Initialize MCP host manager with given config
*/
private async initialize(
signature: string,
mcpConfig: NonNullable<ReturnType<typeof buildMcpClientConfig>>
): Promise<MCPHostManager | undefined> {
this._isInitializing = true;
this._error = null;
this._configSignature = signature;
const hostManager = new MCPHostManager();
this._initPromise = hostManager
.initialize({
servers: mcpConfig.servers,
clientInfo: mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo,
capabilities: mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities
})
.then(() => {
// Check if config changed during initialization
if (this._configSignature !== signature) {
void hostManager.shutdown().catch((err) => {
console.error('[MCP Store] Failed to shutdown stale host manager:', err);
});
return undefined;
}
this._hostManager = hostManager;
this._isInitializing = false;
const toolNames = hostManager.getToolNames();
const serverNames = hostManager.connectedServerNames;
console.log(
`[MCP Store] Initialized: ${serverNames.length} servers, ${toolNames.length} tools`
);
console.log(`[MCP Store] Servers: ${serverNames.join(', ')}`);
console.log(`[MCP Store] Tools: ${toolNames.join(', ')}`);
return hostManager;
})
.catch((error) => {
console.error('[MCP Store] Initialization failed:', error);
this._error = error instanceof Error ? error.message : String(error);
this._isInitializing = false;
void hostManager.shutdown().catch((err) => {
console.error('[MCP Store] Failed to shutdown after error:', err);
});
return undefined;
})
.finally(() => {
if (this._configSignature === signature) {
this._initPromise = null;
}
});
return this._initPromise;
}
/**
* Shutdown MCP host manager and clear state
*/
async shutdown(): Promise<void> {
// Wait for any pending initialization
if (this._initPromise) {
await this._initPromise.catch(() => {});
this._initPromise = null;
}
if (this._hostManager) {
const managerToShutdown = this._hostManager;
this._hostManager = null;
this._configSignature = null;
this._error = null;
try {
await managerToShutdown.shutdown();
console.log('[MCP Store] Host manager shutdown complete');
} catch (error) {
console.error('[MCP Store] Shutdown error:', error);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tool Execution
// ─────────────────────────────────────────────────────────────────────────────
/**
* Execute a tool call via MCP host manager.
* Automatically routes to the appropriate server.
* Also tracks usage statistics for the server.
* Execute a tool call via MCP.
*/
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
if (!this._hostManager) {
throw new Error('MCP host manager not initialized');
}
// Track usage for the server that provides this tool
const serverId = this.getToolServer(toolCall.function.name);
if (serverId) {
const updatedStats = incrementMcpServerUsage(config(), serverId);
settingsStore.updateConfig('mcpServerUsageStats', updatedStats);
}
return this._hostManager.executeTool(toolCall, signal);
return mcpClient.executeTool(toolCall, signal);
}
/**
* Execute a tool by name with arguments.
* Simpler interface for direct tool calls.
*/
async executeToolByName(
toolName: string,
args: Record<string, unknown>,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
if (!this._hostManager) {
throw new Error('MCP host manager not initialized');
}
return this._hostManager.executeToolByName(toolName, args, signal);
return mcpClient.executeToolByName(toolName, args, signal);
}
/**
* Check if a tool exists
*/
hasTool(toolName: string): boolean {
return this._hostManager?.hasTool(toolName) ?? false;
return mcpClient.hasTool(toolName);
}
/**
* Get which server provides a specific tool
*/
getToolServer(toolName: string): string | undefined {
return this._hostManager?.getToolServer(toolName);
return mcpClient.getToolServer(toolName);
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Clear error state
*/
clearError(): void {
this._error = null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Health Check (Settings UI)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get health check state for a specific server
*/
@ -333,13 +172,6 @@ class MCPStore {
return this._healthChecks[serverId] ?? { status: 'idle' };
}
/**
* Set health check state for a specific server
*/
private setHealthCheckState(serverId: string, state: HealthCheckState): void {
this._healthChecks = { ...this._healthChecks, [serverId]: state };
}
/**
* Check if health check has been performed for a server
*/
@ -347,80 +179,11 @@ class MCPStore {
return serverId in this._healthChecks && this._healthChecks[serverId].status !== 'idle';
}
/**
* Parse custom headers from JSON string
*/
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('[MCP Store] Failed to parse custom headers JSON:', headersJson);
}
return undefined;
}
/**
* Run health check for a specific server
*/
async runHealthCheck(server: {
id: string;
url: string;
requestTimeoutSeconds: number;
headers?: string;
}): Promise<void> {
const trimmedUrl = server.url.trim();
if (!trimmedUrl) {
this.setHealthCheckState(server.id, {
status: 'error',
message: 'Please enter a server URL first.'
});
return;
}
this.setHealthCheckState(server.id, { status: 'loading' });
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
const headers = this.parseHeaders(server.headers);
const connection = new MCPServerConnection({
name: server.id,
server: {
url: trimmedUrl,
transport: detectMcpTransportFromUrl(trimmedUrl),
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
requestTimeoutMs: timeoutMs,
headers
},
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
capabilities: DEFAULT_MCP_CONFIG.capabilities
});
try {
await connection.connect();
const tools = connection.tools.map((tool) => ({
name: tool.name,
description: tool.description
}));
this.setHealthCheckState(server.id, { status: 'success', tools });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred';
this.setHealthCheckState(server.id, { status: 'error', message });
} finally {
try {
await connection.disconnect();
} catch (shutdownError) {
console.warn(
'[MCP Store] Failed to cleanly shutdown health check connection',
shutdownError
);
}
}
async runHealthCheck(server: HealthCheckParams): Promise<void> {
return mcpClient.runHealthCheck(server);
}
/**
@ -438,19 +201,17 @@ class MCPStore {
clearAllHealthChecks(): void {
this._healthChecks = {};
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Singleton Instance & Exports
// ─────────────────────────────────────────────────────────────────────────────
/**
* Clear error state
*/
clearError(): void {
this._error = null;
}
}
export const mcpStore = new MCPStore();
// Reactive exports for components
export function mcpHostManager() {
return mcpStore.hostManager;
}
export function mcpIsInitializing() {
return mcpStore.isInitializing;
}
@ -483,7 +244,6 @@ export function mcpToolCount() {
return mcpStore.toolCount;
}
// Health check exports
export function mcpGetHealthCheckState(serverId: string) {
return mcpStore.getHealthCheckState(serverId);
}
@ -492,12 +252,7 @@ export function mcpHasHealthCheck(serverId: string) {
return mcpStore.hasHealthCheck(serverId);
}
export async function mcpRunHealthCheck(server: {
id: string;
url: string;
requestTimeoutSeconds: number;
headers?: string;
}) {
export async function mcpRunHealthCheck(server: HealthCheckParams) {
return mcpStore.runHealthCheck(server);
}

View File

@ -1,8 +1,8 @@
import { SvelteSet } from 'svelte/reactivity';
import { ModelsService } from '$lib/services/models';
import { PropsService } from '$lib/services/props';
import { ModelsService } from '$lib/services/models.service';
import { ServerModelStatus, ModelModality } from '$lib/enums';
import { serverStore } from '$lib/stores/server.svelte';
import { PropsService } from '$lib/services';
/**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
@ -32,9 +32,13 @@ import { serverStore } from '$lib/stores/server.svelte';
* - **Lazy loading**: ensureModelLoaded() loads models on demand
*/
class ModelsStore {
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* State
*
*
*/
models = $state<ModelOption[]>([]);
routerModels = $state<ApiModelDataEntry[]>([]);
@ -59,9 +63,13 @@ class ModelsStore {
*/
propsCacheVersion = $state(0);
// ─────────────────────────────────────────────────────────────────────────────
// Computed Getters
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Computed Getters
*
*
*/
get selectedModel(): ModelOption | null {
if (!this.selectedModelId) return null;
@ -95,22 +103,24 @@ class ModelsStore {
return props.model_path.split(/(\\|\/)/).pop() || null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Modalities
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Modalities
*
*
*/
/**
* Get modalities for a specific model
* Returns cached modalities from model props
*/
getModelModalities(modelId: string): ModelModalities | null {
// First check if modalities are stored in the model option
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) {
return model.modalities;
}
// Fall back to props cache
const props = this.modelPropsCache.get(modelId);
if (props?.modalities) {
return {
@ -181,9 +191,13 @@ class ModelsStore {
return this.modelPropsFetching.has(modelId);
}
// ─────────────────────────────────────────────────────────────────────────────
// Status Queries
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Status Queries
*
*
*/
isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId);
@ -208,9 +222,13 @@ class ModelsStore {
return usage !== undefined && usage.size > 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// Data Fetching
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Data Fetching
*
*
*/
/**
* Fetch list of models from server and detect server role
@ -224,7 +242,6 @@ class ModelsStore {
this.error = null;
try {
// Ensure server props are loaded (for role detection and MODEL mode modalities)
if (!serverStore.props) {
await serverStore.fetch();
}
@ -251,7 +268,6 @@ class ModelsStore {
this.models = models;
// In MODEL mode, populate modalities from serverStore.props (single model)
// WORKAROUND: In MODEL mode, /props returns modalities for the single model,
// but /v1/models doesn't include modalities. We bridge this gap here.
const serverProps = serverStore.props;
@ -260,9 +276,7 @@ class ModelsStore {
vision: serverProps.modalities.vision ?? false,
audio: serverProps.modalities.audio ?? false
};
// Cache props for the single model
this.modelPropsCache.set(this.models[0].model, serverProps);
// Update model with modalities
this.models = this.models.map((model, index) =>
index === 0 ? { ...model, modalities } : model
);
@ -302,7 +316,6 @@ class ModelsStore {
* @returns Props data or null if fetch failed or model not loaded
*/
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
// Return cached props if available
const cached = this.modelPropsCache.get(modelId);
if (cached) return cached;
@ -310,7 +323,6 @@ class ModelsStore {
return null;
}
// Avoid duplicate fetches
if (this.modelPropsFetching.has(modelId)) return null;
this.modelPropsFetching.add(modelId);
@ -335,7 +347,6 @@ class ModelsStore {
const loadedModelIds = this.loadedModelIds;
if (loadedModelIds.length === 0) return;
// Fetch props for each loaded model in parallel
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
try {
@ -357,7 +368,6 @@ class ModelsStore {
return { ...model, modalities };
});
// Increment version to trigger reactivity
this.propsCacheVersion++;
} catch (error) {
console.warn('Failed to fetch modalities for loaded models:', error);
@ -382,16 +392,19 @@ class ModelsStore {
model.model === modelId ? { ...model, modalities } : model
);
// Increment version to trigger reactivity
this.propsCacheVersion++;
} catch (error) {
console.warn(`Failed to update modalities for model ${modelId}:`, error);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Model Selection
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Model Selection
*
*
*/
/**
* Select a model for new conversations
@ -443,9 +456,13 @@ class ModelsStore {
return this.models.some((model) => model.model === modelName);
}
// ─────────────────────────────────────────────────────────────────────────────
// Loading/Unloading Models
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Loading/Unloading Models
*
*
*/
/**
* WORKAROUND: Polling for model status after load/unload operations.
@ -486,7 +503,6 @@ class ModelsStore {
return;
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
}
@ -511,8 +527,6 @@ class ModelsStore {
try {
await ModelsService.load(modelId);
// Poll until model is loaded
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
await this.updateModelModalities(modelId);
@ -562,9 +576,13 @@ class ModelsStore {
await this.loadModel(modelId);
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Utilities
*
*
*/
private toDisplayName(id: string): string {
const segments = id.split(/\\|\//);

View File

@ -1,4 +1,4 @@
import { PropsService } from '$lib/services/props';
import { PropsService } from '$lib/services/props.service';
import { ServerRole } from '$lib/enums';
/**
@ -18,9 +18,13 @@ import { ServerRole } from '$lib/enums';
* - **Default Params**: Server-wide generation defaults
*/
class ServerStore {
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* State
*
*
*/
props = $state<ApiLlamaCppServerProps | null>(null);
loading = $state(false);
@ -28,9 +32,13 @@ class ServerStore {
role = $state<ServerRole | null>(null);
private fetchPromise: Promise<void> | null = null;
// ─────────────────────────────────────────────────────────────────────────────
// Getters
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Getters
*
*
*/
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
return this.props?.default_generation_settings?.params || null;
@ -52,9 +60,13 @@ class ServerStore {
return this.role === ServerRole.MODEL;
}
// ─────────────────────────────────────────────────────────────────────────────
// Data Handling
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Data Handling
*
*
*/
async fetch(): Promise<void> {
if (this.fetchPromise) return this.fetchPromise;
@ -115,9 +127,13 @@ class ServerStore {
this.fetchPromise = null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Utilities
*
*
*/
private detectRole(props: ApiLlamaCppServerProps): void {
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;

View File

@ -33,7 +33,7 @@
import { browser } from '$app/environment';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { ParameterSyncService } from '$lib/services/parameter-sync';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { serverStore } from '$lib/stores/server.svelte';
import {
configToParameterRecord,
@ -47,18 +47,26 @@ import {
} from '$lib/constants/localstorage-keys';
class SettingsStore {
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* State
*
*
*/
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
theme = $state<string>('auto');
isInitialized = $state(false);
userOverrides = $state<Set<string>>(new Set());
// ─────────────────────────────────────────────────────────────────────────────
// Utilities (private helpers)
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Utilities (private helpers)
*
*
*/
/**
* Helper method to get server defaults with null safety
@ -76,9 +84,13 @@ class SettingsStore {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Lifecycle
*
*
*/
/**
* Initialize the settings store by loading from localStorage
@ -130,9 +142,13 @@ class SettingsStore {
this.theme = localStorage.getItem('theme') || 'auto';
}
// ─────────────────────────────────────────────────────────────────────────────
// Config Updates
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Config Updates
*
*
*/
/**
* Update a specific configuration setting
@ -234,9 +250,13 @@ class SettingsStore {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Reset
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Reset
*
*
*/
/**
* Reset configuration to defaults
@ -285,9 +305,13 @@ class SettingsStore {
this.saveConfig();
}
// ─────────────────────────────────────────────────────────────────────────────
// Server Sync
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Server Sync
*
*
*/
/**
* Initialize settings with props defaults when server properties are first loaded
@ -349,9 +373,13 @@ class SettingsStore {
this.saveConfig();
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
*
*
* Utilities
*
*
*/
/**
* Get a specific configuration value

View File

@ -49,4 +49,37 @@ export interface ChatMessageTimings {
predicted_n?: number;
prompt_ms?: number;
prompt_n?: number;
agentic?: ChatMessageAgenticTimings;
}
export interface ChatMessageAgenticTimings {
turns: number;
toolCallsCount: number;
toolsMs: number;
toolCalls?: ChatMessageToolCallTiming[];
perTurn?: ChatMessageAgenticTurnStats[];
llm: {
predicted_n: number;
predicted_ms: number;
prompt_n: number;
prompt_ms: number;
};
}
export interface ChatMessageAgenticTurnStats {
turn: number;
llm: {
predicted_n: number;
predicted_ms: number;
prompt_n: number;
prompt_ms: number;
};
toolCalls: ChatMessageToolCallTiming[];
toolsMs: number;
}
export interface ChatMessageToolCallTiming {
name: string;
duration_ms: number;
success: boolean;
}

View File

@ -11,7 +11,6 @@ export interface DatabaseConversation {
id: string;
lastModified: number;
name: string;
/** Per-chat MCP server overrides. If not set, global settings are used. */
mcpServerOverrides?: McpServerOverride[];
}
@ -42,9 +41,9 @@ export interface DatabaseMessageExtraPdfFile {
type: AttachmentType.PDF;
base64Data: string;
name: string;
content: string; // Text content extracted from PDF
images?: string[]; // Optional: PDF pages as base64 images
processedAsImages: boolean; // Whether PDF was processed as images
content: string;
images?: string[];
processedAsImages: boolean;
}
export interface DatabaseMessageExtraTextFile {
@ -76,17 +75,9 @@ export interface DatabaseMessage {
model?: string;
}
/**
* Represents a single conversation with its associated messages,
* typically used for import/export operations.
*/
export type ExportedConversation = {
conv: DatabaseConversation;
messages: DatabaseMessage[];
};
/**
* Type representing one or more exported conversations.
* Can be a single conversation object or an array of them.
*/
export type ExportedConversations = ExportedConversation | ExportedConversation[];

View File

@ -0,0 +1,93 @@
import type {
ClientCapabilities as SDKClientCapabilities,
Implementation as SDKImplementation,
Tool,
CallToolResult
} from '@modelcontextprotocol/sdk/types.js';
export type { Tool, CallToolResult };
export type ClientCapabilities = SDKClientCapabilities;
export type Implementation = SDKImplementation;
export type MCPTransportType = 'websocket' | 'streamable_http';
export type MCPServerConfig = {
transport?: MCPTransportType;
url: string;
protocols?: string | string[];
headers?: Record<string, string>;
credentials?: RequestCredentials;
handshakeTimeoutMs?: number;
requestTimeoutMs?: number;
capabilities?: ClientCapabilities;
sessionId?: string;
};
export type MCPClientConfig = {
servers: Record<string, MCPServerConfig>;
protocolVersion?: string;
capabilities?: ClientCapabilities;
clientInfo?: Implementation;
requestTimeoutMs?: number;
};
export type MCPToolCallArguments = Record<string, unknown>;
export type MCPToolCall = {
id: string;
function: {
name: string;
arguments: string | MCPToolCallArguments;
};
};
export type MCPServerSettingsEntry = {
id: string;
enabled: boolean;
url: string;
requestTimeoutSeconds: number;
headers?: string;
name?: string;
iconUrl?: string;
};
export interface MCPHostManagerConfig {
servers: MCPClientConfig['servers'];
clientInfo?: Implementation;
capabilities?: ClientCapabilities;
}
export interface OpenAIToolDefinition {
type: 'function';
function: {
name: string;
description?: string;
parameters: Record<string, unknown>;
};
}
export interface ServerStatus {
name: string;
isConnected: boolean;
toolCount: number;
error?: string;
}
export type McpServerUsageStats = Record<string, number>;
export interface MCPServerConnectionConfig {
name: string;
server: MCPServerConfig;
clientInfo?: Implementation;
capabilities?: ClientCapabilities;
}
export interface ToolCallParams {
name: string;
arguments: Record<string, unknown>;
}
export interface ToolExecutionResult {
content: string;
isError: boolean;
}

View File

@ -1,132 +0,0 @@
// Re-export SDK types that we use
import type {
ClientCapabilities as SDKClientCapabilities,
Implementation as SDKImplementation,
Tool,
CallToolResult
} from '@modelcontextprotocol/sdk/types.js';
export type { Tool, CallToolResult };
export type ClientCapabilities = SDKClientCapabilities;
export type Implementation = SDKImplementation;
export type MCPTransportType = 'websocket' | 'streamable_http';
export type MCPServerConfig = {
/** MCP transport type. Defaults to `streamable_http`. */
transport?: MCPTransportType;
/** Remote MCP endpoint URL. */
url: string;
/** Optional WebSocket subprotocol(s). */
protocols?: string | string[];
/** Optional HTTP headers for environments that support them. */
headers?: Record<string, string>;
/** Optional credentials policy for fetch-based transports. */
credentials?: RequestCredentials;
/** Optional handshake timeout override (ms). */
handshakeTimeoutMs?: number;
/** Optional per-server request timeout override (ms). */
requestTimeoutMs?: number;
/** Optional per-server capability overrides. */
capabilities?: ClientCapabilities;
/** Optional pre-negotiated session identifier for Streamable HTTP transport. */
sessionId?: string;
};
export type MCPClientConfig = {
servers: Record<string, MCPServerConfig>;
/** Defaults to `2025-06-18`. */
protocolVersion?: string;
/** Default capabilities advertised during initialize. */
capabilities?: ClientCapabilities;
/** Custom client info to advertise. */
clientInfo?: Implementation;
/** Request timeout when waiting for MCP responses (ms). Default: 30_000. */
requestTimeoutMs?: number;
};
export type MCPToolCallArguments = Record<string, unknown>;
export type MCPToolCall = {
id: string;
function: {
name: string;
arguments: string | MCPToolCallArguments;
};
};
/**
* Raw MCP server configuration entry stored in settings.
*/
export type MCPServerSettingsEntry = {
id: string;
enabled: boolean;
url: string;
requestTimeoutSeconds: number;
/** Optional custom HTTP headers (JSON string of key-value pairs). */
headers?: string;
/** Server name from metadata (fetched during health check). */
name?: string;
/** Server icon URL from metadata (fetched during health check). */
iconUrl?: string;
};
// ─────────────────────────────────────────────────────────────────────────────
// Host Manager Types
// ─────────────────────────────────────────────────────────────────────────────
export interface MCPHostManagerConfig {
/** Server configurations keyed by server name */
servers: MCPClientConfig['servers'];
/** Client info to advertise to all servers */
clientInfo?: Implementation;
/** Default capabilities to advertise */
capabilities?: ClientCapabilities;
}
export interface OpenAIToolDefinition {
type: 'function';
function: {
name: string;
description?: string;
parameters: Record<string, unknown>;
};
}
export interface ServerStatus {
name: string;
isConnected: boolean;
toolCount: number;
error?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Usage Stats
// ─────────────────────────────────────────────────────────────────────────────
export type McpServerUsageStats = Record<string, number>;
// ─────────────────────────────────────────────────────────────────────────────
// Server Connection Types
// ─────────────────────────────────────────────────────────────────────────────
export interface MCPServerConnectionConfig {
/** Unique server name/identifier */
name: string;
/** Server configuration */
server: MCPServerConfig;
/** Client info to advertise */
clientInfo?: Implementation;
/** Capabilities to advertise */
capabilities?: ClientCapabilities;
}
export interface ToolCallParams {
name: string;
arguments: Record<string, unknown>;
}
export interface ToolExecutionResult {
content: string;
isError: boolean;
}

View File

@ -1,8 +1,5 @@
import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
/**
* Model modalities - vision and audio capabilities
*/
export interface ModelModalities {
vision: boolean;
audio: boolean;
@ -14,7 +11,6 @@ export interface ModelOption {
model: string;
description?: string;
capabilities: string[];
/** Model modalities from /props endpoint */
modalities?: ModelModalities;
details?: ApiModelDetails['details'];
meta?: ApiModelDataEntry['meta'];

View File

@ -1,5 +1,6 @@
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import type { ChatMessageTimings } from './chat';
import type { OpenAIToolDefinition } from './mcp';
export type SettingsConfigValue = string | number | boolean;
@ -20,6 +21,7 @@ export interface SettingsChatServiceOptions {
systemMessage?: string;
// Disable reasoning parsing (use 'none' instead of 'auto')
disableReasoningParsing?: boolean;
tools?: OpenAIToolDefinition[];
// Generation parameters
temperature?: number;
max_tokens?: number;

View File

@ -1,5 +1,41 @@
import type { ApiChatMessageData } from '$lib/types/api';
import type { AgenticMessage } from '$lib/types/agentic';
import type { AgenticMessage, AgenticConfig } from '$lib/types/agentic';
import type { SettingsConfigType } from '$lib/types/settings';
import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
import { normalizePositiveNumber } from '$lib/utils/number';
import { hasEnabledMcpServers } from '$lib/utils/mcp';
/**
* 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
*/
export function getAgenticConfig(
settings: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): AgenticConfig {
const maxTurns = normalizePositiveNumber(
settings.agenticMaxTurns,
DEFAULT_AGENTIC_CONFIG.maxTurns
);
const maxToolPreviewLines = normalizePositiveNumber(
settings.agenticMaxToolPreviewLines,
DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines
);
const filterReasoningAfterFirstTurn =
typeof settings.agenticFilterReasoningAfterFirstTurn === 'boolean'
? settings.agenticFilterReasoningAfterFirstTurn
: DEFAULT_AGENTIC_CONFIG.filterReasoningAfterFirstTurn;
return {
enabled: hasEnabledMcpServers(settings, perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
maxTurns,
maxToolPreviewLines,
filterReasoningAfterFirstTurn
};
}
/**
* Converts API messages to agentic format.

View File

@ -0,0 +1,29 @@
/**
* Decode base64 to UTF-8 string using modern APIs.
* Falls back to legacy method if TextDecoder is unavailable.
*
* @param base64 - Base64 encoded string (padding is optional)
* @returns Decoded UTF-8 string, or empty string if decoding fails
*/
export function decodeBase64(base64: string): string {
if (!base64) return '';
const padded = base64 + '=='.slice(0, (4 - (base64.length % 4)) % 4);
try {
const binaryString = atob(padded);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new TextDecoder('utf-8').decode(bytes);
} catch {
// Fallback to legacy method
try {
return decodeURIComponent(escape(atob(padded)));
} catch {
// Return empty string if all decoding fails
return '';
}
}
}

View File

@ -97,3 +97,6 @@ export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files
// Agentic utilities
export { toAgenticMessages } from './agentic';
// Base64 utilities
export { decodeBase64 } from './base64';

View File

@ -1,4 +1,14 @@
import type { MCPTransportType } from '$lib/types/mcp';
import type {
MCPTransportType,
MCPClientConfig,
MCPServerConfig,
MCPServerSettingsEntry,
McpServerUsageStats
} from '$lib/types/mcp';
import type { SettingsConfigType } from '$lib/types/settings';
import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { normalizePositiveNumber } from '$lib/utils/number';
/**
* Represents a key-value pair for HTTP headers.
@ -73,7 +83,7 @@ export function parseHeadersToArray(headersJson: string): HeaderPair[] {
}));
}
} catch {
// Invalid JSON, return empty
return [];
}
return [];
}
@ -91,3 +101,199 @@ export function serializeHeaders(pairs: HeaderPair[]): string {
}
return JSON.stringify(obj);
}
/**
* Parses MCP server settings from a JSON string or array.
* @param rawServers - The raw servers to parse
* @param fallbackRequestTimeoutSeconds - The fallback request timeout seconds
* @returns An empty array if the input is invalid.
*/
export function parseMcpServerSettings(
rawServers: unknown,
fallbackRequestTimeoutSeconds = DEFAULT_MCP_CONFIG.requestTimeoutSeconds
): MCPServerSettingsEntry[] {
if (!rawServers) return [];
let parsed: unknown;
if (typeof rawServers === 'string') {
const trimmed = rawServers.trim();
if (!trimmed) return [];
try {
parsed = JSON.parse(trimmed);
} catch (error) {
console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
return [];
}
} else {
parsed = rawServers;
}
if (!Array.isArray(parsed)) return [];
return parsed.map((entry, index) => {
const requestTimeoutSeconds = normalizePositiveNumber(
(entry as { requestTimeoutSeconds?: unknown })?.requestTimeoutSeconds,
fallbackRequestTimeoutSeconds
);
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
return {
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
url,
requestTimeoutSeconds,
headers: headers || undefined
} satisfies MCPServerSettingsEntry;
});
}
/**
* Builds an MCP server configuration from a server settings entry.
* @param entry - The server settings entry to build the configuration from
* @param connectionTimeoutMs - The connection timeout in milliseconds
* @returns The built server configuration, or undefined if the entry is invalid
*/
function buildServerConfig(
entry: MCPServerSettingsEntry,
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
): MCPServerConfig | undefined {
if (!entry?.url) {
return undefined;
}
let headers: Record<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, ignoring:', entry.headers);
}
}
return {
url: entry.url,
transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
headers
};
}
/**
* TODO - move stateful logic to store
*/
function isServerEnabled(
server: MCPServerSettingsEntry,
perChatOverrides?: McpServerOverride[]
): boolean {
if (perChatOverrides) {
const override = perChatOverrides.find((o) => o.serverId === server.id);
if (override !== undefined) {
return override.enabled;
}
}
return server.enabled;
}
/**
* Builds MCP client configuration from settings.
* Returns undefined if no valid servers are configured.
* @param config - Global settings configuration
* @param perChatOverrides - Optional per-chat server overrides
*/
export function buildMcpClientConfig(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseMcpServerSettings(config.mcpServers);
if (!rawServers.length) {
return undefined;
}
const servers: Record<string, MCPServerConfig> = {};
for (const [index, entry] of rawServers.entries()) {
if (!isServerEnabled(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
};
}
/**
* TODO - move stateful logic to store
*/
export function hasEnabledMcpServers(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): boolean {
return Boolean(buildMcpClientConfig(config, perChatOverrides));
}
/**
* Parses MCP server usage stats from settings.
* @param rawStats - The raw stats to parse
* @returns MCP server usage stats or empty object if invalid
*/
export function parseMcpServerUsageStats(rawStats: unknown): McpServerUsageStats {
if (!rawStats) return {};
if (typeof rawStats === 'string') {
const trimmed = rawStats.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as McpServerUsageStats;
}
} catch {
console.warn('[MCP] Failed to parse mcpServerUsageStats JSON, ignoring value');
}
}
return {};
}
/**
* Gets usage count for a specific server.
* @param config - Global settings configuration
* @param serverId - The server ID to get the usage count for
* @returns The usage count for the server
*/
export function getMcpServerUsageCount(config: SettingsConfigType, serverId: string): number {
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
return stats[serverId] || 0;
}
/**
* Increments usage count for a server and returns updated stats JSON.
* @param config - Global settings configuration
* @param serverId - The server ID to increment the usage count for
* @returns The updated stats JSON
*/
export function incrementMcpServerUsage(config: SettingsConfigType, serverId: string): string {
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
stats[serverId] = (stats[serverId] || 0) + 1;
return JSON.stringify(stats);
}