refactor: Go back to simpler Stores + Services architecture
This commit is contained in:
parent
f7b7ae467e
commit
aff13cc085
Binary file not shown.
|
|
@ -1,707 +0,0 @@
|
|||
/**
|
||||
* AgenticClient - Business Logic Facade for Agentic Loop Orchestration
|
||||
*
|
||||
* Coordinates the multi-turn agentic loop with MCP tools:
|
||||
* - LLM streaming with tool call detection
|
||||
* - Tool execution via MCPClient
|
||||
* - Session state management
|
||||
* - Turn limit enforcement
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **AgenticClient** (this class): Orchestrates multi-turn tool loop
|
||||
* - Uses MCPClient for tool execution
|
||||
* - Uses ChatService for LLM streaming
|
||||
* - Updates agenticStore with reactive state
|
||||
*
|
||||
* - **MCPClient**: Tool execution facade
|
||||
* - **agenticStore**: Reactive state only ($state)
|
||||
*
|
||||
* **Key Features:**
|
||||
* - Multi-turn tool call orchestration
|
||||
* - Automatic routing of tool calls to appropriate MCP servers
|
||||
* - Raw LLM output streaming (UI formatting is separate concern)
|
||||
* - Lazy disconnect after flow completes
|
||||
*/
|
||||
|
||||
import { BaseClient } from './base-client';
|
||||
import { mcpClient } from '$lib/clients';
|
||||
import { ChatService } from '$lib/services';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { agenticStore } from '$lib/stores/agentic.svelte';
|
||||
import type {
|
||||
AgenticMessage,
|
||||
AgenticToolCallList,
|
||||
AgenticConfig,
|
||||
AgenticFlowCallbacks,
|
||||
AgenticFlowOptions,
|
||||
AgenticFlowParams,
|
||||
AgenticFlowResult
|
||||
} from '$lib/types/agentic';
|
||||
import type {
|
||||
ApiChatCompletionToolCall,
|
||||
ApiChatMessageData,
|
||||
ApiChatMessageContentPart
|
||||
} from '$lib/types/api';
|
||||
import type {
|
||||
ChatMessagePromptProgress,
|
||||
ChatMessageTimings,
|
||||
ChatMessageAgenticTimings,
|
||||
ChatMessageToolCallTiming,
|
||||
ChatMessageAgenticTurnStats
|
||||
} from '$lib/types/chat';
|
||||
import type { MCPToolCall } from '$lib/types';
|
||||
import type {
|
||||
DatabaseMessage,
|
||||
DatabaseMessageExtra,
|
||||
DatabaseMessageExtraImageFile
|
||||
} from '$lib/types/database';
|
||||
import { AttachmentType, MessageRole } from '$lib/enums';
|
||||
import { isAbortError } from '$lib/utils';
|
||||
|
||||
/**
|
||||
* Converts API messages to agentic format.
|
||||
*/
|
||||
function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (
|
||||
message.role === MessageRole.ASSISTANT &&
|
||||
message.tool_calls &&
|
||||
message.tool_calls.length > 0
|
||||
) {
|
||||
return {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: message.content,
|
||||
tool_calls: message.tool_calls.map((call, index) => ({
|
||||
id: call.id ?? `call_${index}`,
|
||||
type: (call.type as 'function') ?? 'function',
|
||||
function: {
|
||||
name: call.function?.name ?? '',
|
||||
arguments: call.function?.arguments ?? ''
|
||||
}
|
||||
}))
|
||||
} satisfies AgenticMessage;
|
||||
}
|
||||
|
||||
if (message.role === MessageRole.TOOL && message.tool_call_id) {
|
||||
return {
|
||||
role: MessageRole.TOOL,
|
||||
tool_call_id: message.tool_call_id,
|
||||
content: typeof message.content === 'string' ? message.content : ''
|
||||
} satisfies AgenticMessage;
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role as MessageRole.SYSTEM | MessageRole.USER,
|
||||
content: message.content
|
||||
} satisfies AgenticMessage;
|
||||
});
|
||||
}
|
||||
|
||||
interface AgenticStoreStateCallbacks {
|
||||
setRunning: (conversationId: string, running: boolean) => void;
|
||||
setCurrentTurn: (conversationId: string, turn: number) => void;
|
||||
setTotalToolCalls: (conversationId: string, count: number) => void;
|
||||
setLastError: (conversationId: string, error: Error | null) => void;
|
||||
setStreamingToolCall: (
|
||||
conversationId: string,
|
||||
tc: { name: string; arguments: string } | null
|
||||
) => void;
|
||||
clearStreamingToolCall: (conversationId: string) => void;
|
||||
}
|
||||
|
||||
export class AgenticClient extends BaseClient<AgenticStoreStateCallbacks> {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Agentic Flow
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runs the agentic orchestration loop with MCP tools.
|
||||
* Main entry point called by ChatClient when agentic mode is enabled.
|
||||
*
|
||||
* Coordinates: initial LLM request → tool call detection → tool execution → loop until done.
|
||||
*
|
||||
* @param params - Flow parameters including messages, options, callbacks, and signal
|
||||
* @returns Result indicating if the flow handled the request
|
||||
*/
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
|
||||
const {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onError,
|
||||
onTimings
|
||||
} = callbacks;
|
||||
|
||||
// Get agentic configuration (considering per-chat MCP overrides)
|
||||
const agenticConfig = agenticStore.getConfig(config(), perChatOverrides);
|
||||
if (!agenticConfig.enabled) {
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
// Ensure MCP is initialized with per-chat overrides
|
||||
const initialized = await mcpClient.ensureInitialized(perChatOverrides);
|
||||
if (!initialized) {
|
||||
console.log('[AgenticClient] MCP not initialized, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
const tools = mcpClient.getToolDefinitionsForLLM();
|
||||
if (tools.length === 0) {
|
||||
console.log('[AgenticClient] No tools available, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
console.log(`[AgenticClient] Starting agentic flow with ${tools.length} tools`);
|
||||
|
||||
const normalizedMessages: ApiChatMessageData[] = messages
|
||||
.map((msg) => {
|
||||
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
|
||||
// DatabaseMessage - use ChatService to convert
|
||||
return ChatService.convertDbMessageToApiChatMessageData(
|
||||
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
||||
);
|
||||
}
|
||||
return msg as ApiChatMessageData;
|
||||
})
|
||||
.filter((msg) => {
|
||||
if (msg.role === MessageRole.SYSTEM) {
|
||||
const content = typeof msg.content === 'string' ? msg.content : '';
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
this.store.setRunning(conversationId, true);
|
||||
this.store.setCurrentTurn(conversationId, 0);
|
||||
this.store.setTotalToolCalls(conversationId, 0);
|
||||
this.store.setLastError(conversationId, null);
|
||||
|
||||
// Acquire reference to prevent premature shutdown while this flow is active
|
||||
mcpClient.acquireConnection();
|
||||
|
||||
try {
|
||||
await this.executeAgenticLoop({
|
||||
conversationId,
|
||||
messages: normalizedMessages,
|
||||
options,
|
||||
tools,
|
||||
agenticConfig,
|
||||
callbacks: {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onError,
|
||||
onTimings
|
||||
},
|
||||
signal
|
||||
});
|
||||
return { handled: true };
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
this.store.setLastError(conversationId, normalizedError);
|
||||
onError?.(normalizedError);
|
||||
return { handled: true, error: normalizedError };
|
||||
} finally {
|
||||
this.store.setRunning(conversationId, false);
|
||||
// Release reference - will only shutdown if no other flows are active
|
||||
await mcpClient.releaseConnection().catch((err) => {
|
||||
console.warn('[AgenticClient] Failed to release MCP connection:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAgenticLoop(params: {
|
||||
conversationId: string;
|
||||
messages: ApiChatMessageData[];
|
||||
options: AgenticFlowOptions;
|
||||
tools: ReturnType<typeof mcpClient.getToolDefinitionsForLLM>;
|
||||
agenticConfig: AgenticConfig;
|
||||
callbacks: AgenticFlowCallbacks;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { conversationId, messages, options, tools, agenticConfig, callbacks, signal } = params;
|
||||
const {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onTimings
|
||||
} = callbacks;
|
||||
|
||||
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
|
||||
const allToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let capturedTimings: ChatMessageTimings | undefined;
|
||||
|
||||
const agenticTimings: ChatMessageAgenticTimings = {
|
||||
turns: 0,
|
||||
toolCallsCount: 0,
|
||||
toolsMs: 0,
|
||||
toolCalls: [],
|
||||
perTurn: [],
|
||||
llm: {
|
||||
predicted_n: 0,
|
||||
predicted_ms: 0,
|
||||
prompt_n: 0,
|
||||
prompt_ms: 0
|
||||
}
|
||||
};
|
||||
|
||||
const maxTurns = agenticConfig.maxTurns;
|
||||
const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
|
||||
|
||||
for (let turn = 0; turn < maxTurns; turn++) {
|
||||
this.store.setCurrentTurn(conversationId, turn + 1);
|
||||
agenticTimings.turns = turn + 1;
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let turnContent = '';
|
||||
let turnToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let lastStreamingToolCallName = '';
|
||||
let lastStreamingToolCallArgsLength = 0;
|
||||
|
||||
// Track emitted tool call state for progressive streaming
|
||||
const emittedToolCallStates = new Map<number, { emittedOnce: boolean; lastArgs: string }>();
|
||||
|
||||
let turnTimings: ChatMessageTimings | undefined;
|
||||
|
||||
const turnStats: ChatMessageAgenticTurnStats = {
|
||||
turn: turn + 1,
|
||||
llm: {
|
||||
predicted_n: 0,
|
||||
predicted_ms: 0,
|
||||
prompt_n: 0,
|
||||
prompt_ms: 0
|
||||
},
|
||||
toolCalls: [],
|
||||
toolsMs: 0
|
||||
};
|
||||
|
||||
try {
|
||||
await ChatService.sendMessage(
|
||||
sessionMessages as ApiChatMessageData[],
|
||||
{
|
||||
...options,
|
||||
stream: true,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
onChunk: (chunk: string) => {
|
||||
turnContent += chunk;
|
||||
onChunk?.(chunk);
|
||||
},
|
||||
onReasoningChunk,
|
||||
onToolCallChunk: (serialized: string) => {
|
||||
try {
|
||||
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
|
||||
|
||||
// Emit agentic tags progressively for live UI updates
|
||||
for (let i = 0; i < turnToolCalls.length; i++) {
|
||||
const toolCall = turnToolCalls[i];
|
||||
const toolName = toolCall.function?.name ?? '';
|
||||
const toolArgs = toolCall.function?.arguments ?? '';
|
||||
|
||||
const state = emittedToolCallStates.get(i) || {
|
||||
emittedOnce: false,
|
||||
lastArgs: ''
|
||||
};
|
||||
|
||||
if (!state.emittedOnce) {
|
||||
// First emission: send full header + args
|
||||
let output = `\n\n<<<AGENTIC_TOOL_CALL_START>>>`;
|
||||
output += `\n<<<TOOL_NAME:${toolName}>>>`;
|
||||
output += `\n<<<TOOL_ARGS_START>>>\n`;
|
||||
output += toolArgs;
|
||||
onChunk?.(output);
|
||||
state.emittedOnce = true;
|
||||
state.lastArgs = toolArgs;
|
||||
} else if (toolArgs !== state.lastArgs) {
|
||||
// Subsequent emissions: send only delta
|
||||
const delta = toolArgs.slice(state.lastArgs.length);
|
||||
onChunk?.(delta);
|
||||
state.lastArgs = toolArgs;
|
||||
}
|
||||
|
||||
emittedToolCallStates.set(i, state);
|
||||
}
|
||||
|
||||
// Update store with streaming tool call state for UI visualization
|
||||
// Only update when values actually change to avoid memory pressure
|
||||
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
|
||||
const name = turnToolCalls[0].function.name || '';
|
||||
const args = turnToolCalls[0].function.arguments || '';
|
||||
// Only update if name changed or args grew significantly (every 100 chars)
|
||||
const argsLengthBucket = Math.floor(args.length / 100);
|
||||
if (
|
||||
name !== lastStreamingToolCallName ||
|
||||
argsLengthBucket !== lastStreamingToolCallArgsLength
|
||||
) {
|
||||
lastStreamingToolCallName = name;
|
||||
lastStreamingToolCallArgsLength = argsLengthBucket;
|
||||
this.store.setStreamingToolCall(conversationId, { name, arguments: args });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors during streaming
|
||||
}
|
||||
},
|
||||
onModel,
|
||||
onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => {
|
||||
onTimings?.(timings, progress);
|
||||
if (timings) {
|
||||
capturedTimings = timings;
|
||||
turnTimings = timings;
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
// Completion handled after sendMessage resolves
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
signal
|
||||
);
|
||||
|
||||
this.store.clearStreamingToolCall(conversationId);
|
||||
|
||||
if (turnTimings) {
|
||||
agenticTimings.llm.predicted_n += turnTimings.predicted_n || 0;
|
||||
agenticTimings.llm.predicted_ms += turnTimings.predicted_ms || 0;
|
||||
agenticTimings.llm.prompt_n += turnTimings.prompt_n || 0;
|
||||
agenticTimings.llm.prompt_ms += turnTimings.prompt_ms || 0;
|
||||
|
||||
turnStats.llm.predicted_n = turnTimings.predicted_n || 0;
|
||||
turnStats.llm.predicted_ms = turnTimings.predicted_ms || 0;
|
||||
turnStats.llm.prompt_n = turnTimings.prompt_n || 0;
|
||||
turnStats.llm.prompt_ms = turnTimings.prompt_ms || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
|
||||
onChunk?.(`\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`);
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
throw normalizedError;
|
||||
}
|
||||
|
||||
if (turnToolCalls.length === 0) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
|
||||
if (normalizedCalls.length === 0) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const call of normalizedCalls) {
|
||||
allToolCalls.push({
|
||||
id: call.id,
|
||||
type: call.type,
|
||||
function: call.function ? { ...call.function } : undefined
|
||||
});
|
||||
}
|
||||
this.store.setTotalToolCalls(conversationId, allToolCalls.length);
|
||||
onToolCallChunk?.(JSON.stringify(allToolCalls));
|
||||
|
||||
sessionMessages.push({
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: turnContent || undefined,
|
||||
tool_calls: normalizedCalls
|
||||
});
|
||||
|
||||
for (const toolCall of normalizedCalls) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tool call tags were already emitted during streaming via onToolCallChunk
|
||||
// Start timing for tool execution
|
||||
const toolStartTime = performance.now();
|
||||
|
||||
const mcpCall: MCPToolCall = {
|
||||
id: toolCall.id,
|
||||
function: {
|
||||
name: toolCall.function.name,
|
||||
arguments: toolCall.function.arguments
|
||||
}
|
||||
};
|
||||
|
||||
let result: string;
|
||||
let toolSuccess = true;
|
||||
|
||||
try {
|
||||
const executionResult = await mcpClient.executeTool(mcpCall, signal);
|
||||
result = executionResult.content;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
toolSuccess = false;
|
||||
}
|
||||
|
||||
const toolDurationMs = performance.now() - toolStartTime;
|
||||
|
||||
const toolTiming: ChatMessageToolCallTiming = {
|
||||
name: toolCall.function.name,
|
||||
duration_ms: Math.round(toolDurationMs),
|
||||
success: toolSuccess
|
||||
};
|
||||
|
||||
agenticTimings.toolCalls!.push(toolTiming);
|
||||
agenticTimings.toolCallsCount++;
|
||||
agenticTimings.toolsMs += Math.round(toolDurationMs);
|
||||
|
||||
turnStats.toolCalls.push(toolTiming);
|
||||
turnStats.toolsMs += Math.round(toolDurationMs);
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { cleanedResult, attachments } = this.extractBase64Attachments(result);
|
||||
if (attachments.length > 0) {
|
||||
onAttachments?.(attachments);
|
||||
}
|
||||
|
||||
this.emitToolCallResult(cleanedResult, maxToolPreviewLines, onChunk);
|
||||
|
||||
// Add tool result to session
|
||||
// If images were extracted, include them as content parts so the model
|
||||
// can describe them immediately in the same agentic loop
|
||||
const contentParts: ApiChatMessageContentPart[] = [{ type: 'text', text: cleanedResult }];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.type === AttachmentType.IMAGE) {
|
||||
contentParts.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: (attachment as DatabaseMessageExtraImageFile).base64Url }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sessionMessages.push({
|
||||
role: MessageRole.TOOL,
|
||||
tool_call_id: toolCall.id,
|
||||
content: contentParts.length === 1 ? cleanedResult : contentParts
|
||||
});
|
||||
}
|
||||
|
||||
// Save per-turn stats (only if there were tool calls in this turn)
|
||||
if (turnStats.toolCalls.length > 0) {
|
||||
agenticTimings.perTurn!.push(turnStats);
|
||||
}
|
||||
}
|
||||
|
||||
onChunk?.('\n\n```\nTurn limit reached\n```\n');
|
||||
onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Timing & Statistics
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds final timings object with agentic stats.
|
||||
* Single-turn flows return original timings; multi-turn includes aggregated stats.
|
||||
*/
|
||||
private buildFinalTimings(
|
||||
capturedTimings: ChatMessageTimings | undefined,
|
||||
agenticTimings: ChatMessageAgenticTimings
|
||||
): ChatMessageTimings | undefined {
|
||||
// If no tool calls were made, this was effectively a single-turn flow
|
||||
// Return the original timings without agentic data
|
||||
if (agenticTimings.toolCallsCount === 0) {
|
||||
return capturedTimings;
|
||||
}
|
||||
|
||||
const finalTimings: ChatMessageTimings = {
|
||||
// Use the last turn's values as the "current" values for backward compatibility
|
||||
predicted_n: capturedTimings?.predicted_n,
|
||||
predicted_ms: capturedTimings?.predicted_ms,
|
||||
prompt_n: capturedTimings?.prompt_n,
|
||||
prompt_ms: capturedTimings?.prompt_ms,
|
||||
cache_n: capturedTimings?.cache_n,
|
||||
agentic: agenticTimings
|
||||
};
|
||||
|
||||
return finalTimings;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Tool Call Processing
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
|
||||
if (!toolCalls) return [];
|
||||
return toolCalls.map((call, index) => ({
|
||||
id: call?.id ?? `tool_${index}`,
|
||||
type: (call?.type as 'function') ?? 'function',
|
||||
function: {
|
||||
name: call?.function?.name ?? '',
|
||||
arguments: call?.function?.arguments ?? ''
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit tool call result and end marker.
|
||||
*/
|
||||
private emitToolCallResult(
|
||||
result: string,
|
||||
maxLines: number,
|
||||
emit?: (chunk: string) => void
|
||||
): void {
|
||||
if (!emit) return;
|
||||
|
||||
let output = '';
|
||||
output += `\n<<<TOOL_ARGS_END>>>`;
|
||||
// Don't wrap in code fences - result may already be markdown with its own code blocks
|
||||
const lines = result.split('\n');
|
||||
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
|
||||
output += `\n${trimmedLines.join('\n')}`;
|
||||
|
||||
output += `\n<<<AGENTIC_TOOL_CALL_END>>>\n`;
|
||||
emit(output);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Utilities
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
private extractBase64Attachments(result: string): {
|
||||
cleanedResult: string;
|
||||
attachments: DatabaseMessageExtra[];
|
||||
} {
|
||||
if (!result.trim()) {
|
||||
return { cleanedResult: result, attachments: [] };
|
||||
}
|
||||
|
||||
const lines = result.split('\n');
|
||||
const attachments: DatabaseMessageExtra[] = [];
|
||||
let attachmentIndex = 0;
|
||||
|
||||
const cleanedLines = lines.map((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
const match = trimmedLine.match(/^data:([^;]+);base64,([A-Za-z0-9+/]+=*)$/);
|
||||
if (!match) return line;
|
||||
|
||||
const mimeType = match[1].toLowerCase();
|
||||
const base64Data = match[2];
|
||||
if (!base64Data) return line;
|
||||
|
||||
attachmentIndex += 1;
|
||||
const name = this.buildAttachmentName(mimeType, attachmentIndex);
|
||||
|
||||
if (mimeType.startsWith('image/')) {
|
||||
attachments.push({
|
||||
type: AttachmentType.IMAGE,
|
||||
name,
|
||||
base64Url: trimmedLine
|
||||
});
|
||||
return `[Attachment saved: ${name}]`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
return {
|
||||
cleanedResult: cleanedLines.join('\n'),
|
||||
attachments
|
||||
};
|
||||
}
|
||||
|
||||
private buildAttachmentName(mimeType: string, index: number): string {
|
||||
const extensionMap: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp'
|
||||
};
|
||||
|
||||
const extension = extensionMap[mimeType] ?? 'img';
|
||||
const timestamp = Date.now();
|
||||
return `mcp-attachment-${timestamp}-${index}.${extension}`;
|
||||
}
|
||||
|
||||
clearError(conversationId: string): void {
|
||||
this.store.setLastError(conversationId, null);
|
||||
}
|
||||
}
|
||||
|
||||
export const agenticClient = new AgenticClient();
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
/**
|
||||
* BaseClient - Abstract base class for client classes
|
||||
*
|
||||
* Provides common store callback management pattern used by all clients.
|
||||
* Clients extend this class to inherit the store callback infrastructure.
|
||||
*
|
||||
* **Usage:**
|
||||
* ```typescript
|
||||
* interface MyStoreCallbacks {
|
||||
* setSomeState: (value: string) => void;
|
||||
* }
|
||||
*
|
||||
* class MyClient extends BaseClient<MyStoreCallbacks> {
|
||||
* doSomething() {
|
||||
* this.store.setSomeState('value');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class BaseClient<TCallbacks> {
|
||||
private _storeCallbacks: TCallbacks | null = null;
|
||||
|
||||
/**
|
||||
* Sets callbacks for store state updates.
|
||||
* Called by the corresponding store during initialization.
|
||||
*/
|
||||
setStoreCallbacks(callbacks: TCallbacks): void {
|
||||
this._storeCallbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the store callbacks, throwing if not initialized.
|
||||
* Use this in derived classes to access store callbacks safely.
|
||||
*/
|
||||
protected get store(): TCallbacks {
|
||||
if (!this._storeCallbacks) {
|
||||
throw new Error(`${this.constructor.name}: Store callbacks not initialized`);
|
||||
}
|
||||
return this._storeCallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if store callbacks have been initialized.
|
||||
*/
|
||||
protected get hasStoreCallbacks(): boolean {
|
||||
return this._storeCallbacks !== null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,718 +0,0 @@
|
|||
/**
|
||||
* ConversationsClient - Business Logic Facade for Conversation Operations
|
||||
*
|
||||
* Coordinates conversation lifecycle, persistence, and navigation.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ConversationsClient** (this class): Business logic facade
|
||||
* - Uses DatabaseService for IndexedDB operations
|
||||
* - Updates conversationsStore with reactive state
|
||||
* - Handles CRUD, import/export, branch navigation
|
||||
*
|
||||
* - **DatabaseService**: Stateless IndexedDB layer
|
||||
* - **conversationsStore**: Reactive state only ($state)
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Conversation lifecycle (create, load, delete)
|
||||
* - Message management and tree navigation
|
||||
* - MCP server per-chat overrides
|
||||
* - Import/Export functionality
|
||||
* - Title management with confirmation
|
||||
*/
|
||||
|
||||
import { BaseClient } from './base-client';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
interface ConversationsStoreStateCallbacks {
|
||||
getConversations: () => DatabaseConversation[];
|
||||
setConversations: (conversations: DatabaseConversation[]) => void;
|
||||
getActiveConversation: () => DatabaseConversation | null;
|
||||
setActiveConversation: (conversation: DatabaseConversation | null) => void;
|
||||
getActiveMessages: () => DatabaseMessage[];
|
||||
setActiveMessages: (messages: DatabaseMessage[]) => void;
|
||||
updateActiveMessages: (updater: (messages: DatabaseMessage[]) => DatabaseMessage[]) => void;
|
||||
setInitialized: (initialized: boolean) => void;
|
||||
getPendingMcpServerOverrides: () => McpServerOverride[];
|
||||
setPendingMcpServerOverrides: (overrides: McpServerOverride[]) => void;
|
||||
getTitleUpdateConfirmationCallback: () =>
|
||||
| ((currentTitle: string, newTitle: string) => Promise<boolean>)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export class ConversationsClient extends BaseClient<ConversationsStoreStateCallbacks> {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Lifecycle
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initializes the conversations by loading from the database.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.loadConversations();
|
||||
this.store.setInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize conversations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all conversations from the database
|
||||
*/
|
||||
async loadConversations(): Promise<void> {
|
||||
const conversations = await DatabaseService.getAllConversations();
|
||||
this.store.setConversations(conversations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new conversation and navigates to it
|
||||
* @param name - Optional name for the conversation
|
||||
* @returns The ID of the created conversation
|
||||
*/
|
||||
async createConversation(name?: string): Promise<string> {
|
||||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||
const conversation = await DatabaseService.createConversation(conversationName);
|
||||
|
||||
const pendingOverrides = this.store.getPendingMcpServerOverrides();
|
||||
if (pendingOverrides.length > 0) {
|
||||
// Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
|
||||
const plainOverrides = pendingOverrides.map((o) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}));
|
||||
conversation.mcpServerOverrides = plainOverrides;
|
||||
await DatabaseService.updateConversation(conversation.id, {
|
||||
mcpServerOverrides: plainOverrides
|
||||
});
|
||||
this.store.setPendingMcpServerOverrides([]);
|
||||
}
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
this.store.setConversations([conversation, ...conversations]);
|
||||
this.store.setActiveConversation(conversation);
|
||||
this.store.setActiveMessages([]);
|
||||
|
||||
await goto(`#/chat/${conversation.id}`);
|
||||
|
||||
return conversation.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a specific conversation and its messages
|
||||
* @param convId - The conversation ID to load
|
||||
* @returns True if conversation was loaded successfully
|
||||
*/
|
||||
async loadConversation(convId: string): Promise<boolean> {
|
||||
try {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
|
||||
if (!conversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.store.setPendingMcpServerOverrides([]);
|
||||
this.store.setActiveConversation(conversation);
|
||||
|
||||
if (conversation.currNode) {
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
const filteredMessages = filterByLeafNodeId(
|
||||
allMessages,
|
||||
conversation.currNode,
|
||||
false
|
||||
) as DatabaseMessage[];
|
||||
this.store.setActiveMessages(filteredMessages);
|
||||
} else {
|
||||
const messages = await DatabaseService.getConversationMessages(convId);
|
||||
this.store.setActiveMessages(messages);
|
||||
}
|
||||
|
||||
// Run MCP health checks for enabled servers in this conversation
|
||||
this.runMcpHealthChecksForConversation(conversation.mcpServerOverrides);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs MCP health checks for servers enabled in a conversation.
|
||||
* Runs asynchronously in the background without blocking conversation loading.
|
||||
* @param mcpServerOverrides - The conversation's MCP server overrides
|
||||
*/
|
||||
private runMcpHealthChecksForConversation(mcpServerOverrides?: McpServerOverride[]): void {
|
||||
if (!mcpServerOverrides?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledServers = mcpStore.getEnabledServersForConversation(mcpServerOverrides);
|
||||
|
||||
if (enabledServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ConversationsClient] Running health checks for ${enabledServers.length} MCP server(s)`
|
||||
);
|
||||
|
||||
// Run health checks in background (don't await)
|
||||
mcpClient.runHealthChecksForServers(enabledServers).catch((error) => {
|
||||
console.warn('[ConversationsClient] MCP health checks failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Conversation CRUD
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clears the active conversation and messages.
|
||||
*/
|
||||
clearActiveConversation(): void {
|
||||
this.store.setActiveConversation(null);
|
||||
this.store.setActiveMessages([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a conversation and all its messages
|
||||
* @param convId - The conversation ID to delete
|
||||
*/
|
||||
async deleteConversation(convId: string): Promise<void> {
|
||||
try {
|
||||
await DatabaseService.deleteConversation(convId);
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
this.store.setConversations(conversations.filter((c) => c.id !== convId));
|
||||
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (activeConv?.id === convId) {
|
||||
this.clearActiveConversation();
|
||||
await goto(`?new_chat=true#/`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all conversations and their messages
|
||||
*/
|
||||
async deleteAll(): Promise<void> {
|
||||
try {
|
||||
const allConversations = await DatabaseService.getAllConversations();
|
||||
|
||||
for (const conv of allConversations) {
|
||||
await DatabaseService.deleteConversation(conv.id);
|
||||
}
|
||||
|
||||
this.clearActiveConversation();
|
||||
this.store.setConversations([]);
|
||||
|
||||
toast.success('All conversations deleted');
|
||||
|
||||
await goto(`?new_chat=true#/`);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete all conversations:', error);
|
||||
toast.error('Failed to delete conversations');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Message Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Refreshes active messages based on currNode after branch navigation.
|
||||
*/
|
||||
async refreshActiveMessages(): Promise<void> {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (!activeConv) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(activeConv.id);
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
this.store.setActiveMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const leafNodeId =
|
||||
activeConv.currNode ||
|
||||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
|
||||
|
||||
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
||||
|
||||
this.store.setActiveMessages(currentPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all messages for a specific conversation
|
||||
* @param convId - The conversation ID
|
||||
* @returns Array of messages
|
||||
*/
|
||||
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||
return await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Title Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Updates the name of a conversation.
|
||||
* @param convId - The conversation ID to update
|
||||
* @param name - The new name for the conversation
|
||||
*/
|
||||
async updateConversationName(convId: string, name: string): Promise<void> {
|
||||
try {
|
||||
await DatabaseService.updateConversation(convId, { name });
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
const convIndex = conversations.findIndex((c) => c.id === convId);
|
||||
|
||||
if (convIndex !== -1) {
|
||||
conversations[convIndex].name = name;
|
||||
this.store.setConversations([...conversations]);
|
||||
}
|
||||
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (activeConv?.id === convId) {
|
||||
this.store.setActiveConversation({ ...activeConv, name });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation name:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation title with optional confirmation dialog based on settings
|
||||
* @param convId - The conversation ID to update
|
||||
* @param newTitle - The new title content
|
||||
* @returns True if title was updated, false if cancelled
|
||||
*/
|
||||
async updateConversationTitleWithConfirmation(
|
||||
convId: string,
|
||||
newTitle: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const currentConfig = config();
|
||||
const onConfirmationNeeded = this.store.getTitleUpdateConfirmationCallback();
|
||||
|
||||
if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return false;
|
||||
|
||||
const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
|
||||
if (!shouldUpdate) return false;
|
||||
}
|
||||
|
||||
await this.updateConversationName(convId, newTitle);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation title with confirmation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation lastModified timestamp and moves it to top of list
|
||||
*/
|
||||
updateConversationTimestamp(): void {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (!activeConv) return;
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
const chatIndex = conversations.findIndex((c) => c.id === activeConv.id);
|
||||
|
||||
if (chatIndex !== -1) {
|
||||
conversations[chatIndex].lastModified = Date.now();
|
||||
const updatedConv = conversations.splice(chatIndex, 1)[0];
|
||||
this.store.setConversations([updatedConv, ...conversations]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current node of the active conversation
|
||||
* @param nodeId - The new current node ID
|
||||
*/
|
||||
async updateCurrentNode(nodeId: string): Promise<void> {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (!activeConv) return;
|
||||
|
||||
await DatabaseService.updateCurrentNode(activeConv.id, nodeId);
|
||||
this.store.setActiveConversation({ ...activeConv, currNode: nodeId });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Branch Navigation
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Navigates to a specific sibling branch by updating currNode and refreshing messages.
|
||||
* @param siblingId - The sibling message ID to navigate to
|
||||
*/
|
||||
async navigateToSibling(siblingId: string): Promise<void> {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (!activeConv) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(activeConv.id);
|
||||
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
||||
const activeMessages = this.store.getActiveMessages();
|
||||
const currentFirstUserMessage = activeMessages.find(
|
||||
(m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
|
||||
);
|
||||
|
||||
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
||||
|
||||
await DatabaseService.updateCurrentNode(activeConv.id, currentLeafNodeId);
|
||||
this.store.setActiveConversation({ ...activeConv, currNode: currentLeafNodeId });
|
||||
await this.refreshActiveMessages();
|
||||
|
||||
const updatedActiveMessages = this.store.getActiveMessages();
|
||||
if (rootMessage && updatedActiveMessages.length > 0) {
|
||||
const newFirstUserMessage = updatedActiveMessages.find(
|
||||
(m) => m.role === MessageRole.USER && m.parent === rootMessage.id
|
||||
);
|
||||
|
||||
if (
|
||||
newFirstUserMessage &&
|
||||
newFirstUserMessage.content.trim() &&
|
||||
(!currentFirstUserMessage ||
|
||||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
|
||||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
|
||||
) {
|
||||
await this.updateConversationTitleWithConfirmation(
|
||||
activeConv.id,
|
||||
newFirstUserMessage.content.trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* MCP Server Overrides
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets MCP server override for a specific server in the active conversation.
|
||||
* Falls back to pending overrides if no active conversation exists.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns The override if set, undefined if using global setting
|
||||
*/
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (activeConv) {
|
||||
return activeConv.mcpServerOverrides?.find((o: McpServerOverride) => o.serverId === serverId);
|
||||
}
|
||||
return this.store.getPendingMcpServerOverrides().find((o) => o.serverId === serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an MCP server is enabled for the active conversation.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns True if server is enabled for this conversation
|
||||
*/
|
||||
isMcpServerEnabledForChat(serverId: string): boolean {
|
||||
const override = this.getMcpServerOverride(serverId);
|
||||
return override?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes MCP server override for the active conversation.
|
||||
* If no conversation exists, stores as pending override.
|
||||
* @param serverId - The server ID to override
|
||||
* @param enabled - The enabled state, or undefined to remove override
|
||||
*/
|
||||
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
|
||||
if (!activeConv) {
|
||||
this.setPendingMcpServerOverride(serverId, enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
|
||||
const currentOverrides = (activeConv.mcpServerOverrides || []).map((o: McpServerOverride) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}));
|
||||
let newOverrides: McpServerOverride[];
|
||||
|
||||
if (enabled === undefined) {
|
||||
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
|
||||
} else {
|
||||
const existingIndex = currentOverrides.findIndex(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
newOverrides = [...currentOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
} else {
|
||||
newOverrides = [...currentOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
|
||||
await DatabaseService.updateConversation(activeConv.id, {
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
});
|
||||
|
||||
const updatedConv = {
|
||||
...activeConv,
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
};
|
||||
this.store.setActiveConversation(updatedConv);
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
const convIndex = conversations.findIndex((c) => c.id === activeConv.id);
|
||||
if (convIndex !== -1) {
|
||||
conversations[convIndex].mcpServerOverrides =
|
||||
newOverrides.length > 0 ? newOverrides : undefined;
|
||||
this.store.setConversations([...conversations]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles MCP server enabled state for the active conversation.
|
||||
* @param serverId - The server ID to toggle
|
||||
*/
|
||||
async toggleMcpServerForChat(serverId: string): Promise<void> {
|
||||
const currentEnabled = this.isMcpServerEnabledForChat(serverId);
|
||||
await this.setMcpServerOverride(serverId, !currentEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes MCP server override for the active conversation.
|
||||
* @param serverId - The server ID to remove override for
|
||||
*/
|
||||
async removeMcpServerOverride(serverId: string): Promise<void> {
|
||||
await this.setMcpServerOverride(serverId, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes a pending MCP server override (for new conversations).
|
||||
*/
|
||||
private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
|
||||
const pendingOverrides = this.store.getPendingMcpServerOverrides();
|
||||
|
||||
if (enabled === undefined) {
|
||||
this.store.setPendingMcpServerOverrides(
|
||||
pendingOverrides.filter((o) => o.serverId !== serverId)
|
||||
);
|
||||
} else {
|
||||
const existingIndex = pendingOverrides.findIndex((o) => o.serverId === serverId);
|
||||
if (existingIndex >= 0) {
|
||||
const newOverrides = [...pendingOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
this.store.setPendingMcpServerOverrides(newOverrides);
|
||||
} else {
|
||||
this.store.setPendingMcpServerOverrides([...pendingOverrides, { serverId, enabled }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all pending MCP server overrides.
|
||||
*/
|
||||
clearPendingMcpServerOverrides(): void {
|
||||
this.store.setPendingMcpServerOverrides([]);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import & Export
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Downloads a conversation as JSON file.
|
||||
* @param convId - The conversation ID to download
|
||||
*/
|
||||
async downloadConversation(convId: string): Promise<void> {
|
||||
let conversation: DatabaseConversation | null;
|
||||
let messages: DatabaseMessage[];
|
||||
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (activeConv?.id === convId) {
|
||||
conversation = activeConv;
|
||||
messages = this.store.getActiveMessages();
|
||||
} else {
|
||||
conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return;
|
||||
messages = await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
this.triggerDownload({ conv: conversation, messages });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all conversations with their messages as a JSON file
|
||||
* @returns The list of exported conversations
|
||||
*/
|
||||
async exportAllConversations(): Promise<DatabaseConversation[]> {
|
||||
const allConversations = await DatabaseService.getAllConversations();
|
||||
|
||||
if (allConversations.length === 0) {
|
||||
throw new Error('No conversations to export');
|
||||
}
|
||||
|
||||
const allData = await Promise.all(
|
||||
allConversations.map(async (conv) => {
|
||||
const messages = await DatabaseService.getConversationMessages(conv.id);
|
||||
return { conv, messages };
|
||||
})
|
||||
);
|
||||
|
||||
const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`All conversations (${allConversations.length}) prepared for download`);
|
||||
|
||||
return allConversations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports conversations from a JSON file
|
||||
* Opens file picker and processes the selected file
|
||||
* @returns The list of imported conversations
|
||||
*/
|
||||
async importConversations(): Promise<DatabaseConversation[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement)?.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
reject(new Error('No file selected'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsedData = JSON.parse(text);
|
||||
let importedData: ExportedConversations;
|
||||
|
||||
if (Array.isArray(parsedData)) {
|
||||
importedData = parsedData;
|
||||
} else if (
|
||||
parsedData &&
|
||||
typeof parsedData === 'object' &&
|
||||
'conv' in parsedData &&
|
||||
'messages' in parsedData
|
||||
) {
|
||||
importedData = [parsedData];
|
||||
} else {
|
||||
throw new Error('Invalid file format');
|
||||
}
|
||||
|
||||
const result = await DatabaseService.importConversations(importedData);
|
||||
toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
|
||||
|
||||
await this.loadConversations();
|
||||
|
||||
const importedConversations = (
|
||||
Array.isArray(importedData) ? importedData : [importedData]
|
||||
).map((item) => item.conv);
|
||||
|
||||
resolve(importedConversations);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('Failed to import conversations:', err);
|
||||
toast.error('Import failed', { description: message });
|
||||
reject(new Error(`Import failed: ${message}`));
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports conversations from provided data (without file picker)
|
||||
* @param data - Array of conversation data with messages
|
||||
* @returns Import result with counts
|
||||
*/
|
||||
async importConversationsData(
|
||||
data: ExportedConversations
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
const result = await DatabaseService.importConversations(data);
|
||||
await this.loadConversations();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers file download in browser
|
||||
*/
|
||||
private triggerDownload(data: ExportedConversations, filename?: string): void {
|
||||
const conversation =
|
||||
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
|
||||
|
||||
if (!conversation) {
|
||||
console.error('Invalid data: missing conversation');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationName = conversation.name?.trim() || '';
|
||||
const truncatedSuffix = conversationName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/gi, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.substring(0, 20);
|
||||
const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFilename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const conversationsClient = new ConversationsClient();
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
/**
|
||||
* Clients Module - Business Logic Facades
|
||||
*
|
||||
* This module exports all client classes which coordinate business logic:
|
||||
* - MCPClient: MCP connection management and tool execution
|
||||
* - ChatClient: Message operations, streaming, branching
|
||||
* - AgenticClient: Multi-turn tool loop orchestration
|
||||
* - ConversationsClient: Conversation CRUD and message management
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Clients coordinate between Services (stateless API) and Stores (reactive state)
|
||||
* - Clients contain business logic, orchestration, and error handling
|
||||
* - Stores only hold reactive state and delegate to Clients
|
||||
*
|
||||
* @see services/ for stateless API operations
|
||||
* @see stores/ for reactive state
|
||||
*/
|
||||
|
||||
// Base Client
|
||||
export { BaseClient } from './base-client';
|
||||
|
||||
// MCP Client
|
||||
export { MCPClient, mcpClient } from './mcp.client';
|
||||
|
||||
// Chat Client
|
||||
export { ChatClient, chatClient } from './chat.client';
|
||||
|
||||
// Agentic Client
|
||||
export { AgenticClient, agenticClient } from './agentic.client';
|
||||
|
||||
// Conversations Client
|
||||
export { ConversationsClient, conversationsClient } from './conversations.client';
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { debounce } from '$lib/utils';
|
||||
|
|
@ -81,14 +80,14 @@
|
|||
try {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
const initialized = await mcpClient.ensureInitialized(perChatOverrides);
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
prompts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
prompts = await mcpClient.getAllPrompts();
|
||||
prompts = await mcpStore.getAllPrompts();
|
||||
} catch (error) {
|
||||
console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
|
||||
prompts = [];
|
||||
|
|
@ -123,7 +122,7 @@
|
|||
onClose?.();
|
||||
|
||||
try {
|
||||
const result = await mcpClient.getPrompt(prompt.serverName, prompt.name, args);
|
||||
const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
|
||||
onPromptLoadComplete?.(placeholderId, result);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
|
|
@ -158,7 +157,7 @@
|
|||
loadingSuggestions[argName] = true;
|
||||
|
||||
try {
|
||||
const result = await mcpClient.getPromptCompletions(
|
||||
const result = await mcpStore.getPromptCompletions(
|
||||
selectedPrompt.serverName,
|
||||
selectedPrompt.name,
|
||||
argName,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { McpLogo, McpServersSettings } from '$lib/components/app';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
|
||||
interface Props {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
|
@ -13,7 +12,7 @@
|
|||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
mcpClient.runHealthChecksForServers(mcpStore.getServers());
|
||||
mcpStore.runHealthChecksForServers(mcpStore.getServers());
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
import {
|
||||
McpServerCardActions,
|
||||
McpServerCardDeleteDialog,
|
||||
|
|
@ -62,7 +61,7 @@
|
|||
let editFormRef: McpServerCardEditForm | null = $state(null);
|
||||
|
||||
function handleHealthCheck() {
|
||||
mcpClient.runHealthCheck(server);
|
||||
mcpStore.runHealthCheck(server);
|
||||
}
|
||||
|
||||
async function startEditing() {
|
||||
|
|
@ -87,7 +86,7 @@
|
|||
isEditing = false;
|
||||
|
||||
if (server.enabled && url) {
|
||||
setTimeout(() => mcpClient.runHealthCheck({ ...server, url }), 100);
|
||||
setTimeout(() => mcpStore.runHealthCheck({ ...server, url }), 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
import { DropdownMenuSearchable, McpActiveServersAvatars } from '$lib/components/app';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
import { getFaviconUrl } from '$lib/utils';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
|
|
@ -55,7 +54,7 @@
|
|||
|
||||
function handleDropdownOpen(open: boolean) {
|
||||
if (open) {
|
||||
mcpClient.runHealthChecksForServers(mcpServers);
|
||||
mcpStore.runHealthChecksForServers(mcpServers);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ export { default as McpLogo } from './McpLogo.svelte';
|
|||
* - Composes header, tools list, logs, and actions sub-components
|
||||
* - Manages local edit/delete state
|
||||
* - Reads health state from mcpStore
|
||||
* - Triggers health checks via mcpClient
|
||||
* - Triggers health checks via mcpStore
|
||||
*
|
||||
* **Features:**
|
||||
* - Server header with favicon, name, version, and toggle
|
||||
|
|
|
|||
|
|
@ -1,46 +1,61 @@
|
|||
/**
|
||||
* agenticStore - Reactive State Store for Agentic Loop
|
||||
* agenticStore - Reactive State Store for Agentic Loop Orchestration
|
||||
*
|
||||
* This store contains ONLY reactive state ($state).
|
||||
* All business logic is delegated to AgenticClient.
|
||||
* Manages multi-turn agentic loop with MCP tools:
|
||||
* - LLM streaming with tool call detection
|
||||
* - Tool execution via mcpStore
|
||||
* - Session state management
|
||||
* - Turn limit enforcement
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **AgenticClient**: Business logic facade (loop orchestration, tool execution)
|
||||
* - **MCPClient**: Tool execution via MCP servers
|
||||
* - **agenticStore** (this): Reactive state for UI components
|
||||
* - **ChatService**: Stateless API layer (sendMessage, streaming)
|
||||
* - **mcpStore**: MCP connection management and tool execution
|
||||
* - **agenticStore** (this): Reactive state + business logic
|
||||
*
|
||||
* **Responsibilities:**
|
||||
* - Hold per-conversation reactive state for UI binding
|
||||
* - Provide getters for computed values (scoped by conversationId)
|
||||
* - Expose setters for AgenticClient to update state
|
||||
* - Forward method calls to AgenticClient
|
||||
* - Track sampling requests for debugging
|
||||
*
|
||||
* **Per-Conversation Architecture:**
|
||||
* - Each conversation has its own AgenticSession
|
||||
* - Parallel agentic flows in different chats don't interfere
|
||||
* - Sessions are created on-demand and cleaned up when done
|
||||
*
|
||||
* @see AgenticClient in clients/agentic/ for business logic
|
||||
* @see MCPClient in clients/mcp/ for tool execution
|
||||
* @see ChatService in services/chat.service.ts for API operations
|
||||
* @see mcpStore in stores/mcp.svelte.ts for MCP operations
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { ChatService } from '$lib/services';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { isAbortError } from '$lib/utils';
|
||||
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
|
||||
import { AttachmentType, MessageRole } from '$lib/enums';
|
||||
import type {
|
||||
AgenticFlowParams,
|
||||
AgenticFlowResult,
|
||||
AgenticSession,
|
||||
AgenticConfig,
|
||||
SettingsConfigType,
|
||||
McpServerOverride
|
||||
McpServerOverride,
|
||||
MCPToolCall
|
||||
} from '$lib/types';
|
||||
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { agenticClient } from '$lib/clients/agentic.client';
|
||||
import type {
|
||||
AgenticMessage,
|
||||
AgenticToolCallList,
|
||||
AgenticFlowCallbacks,
|
||||
AgenticFlowOptions
|
||||
} from '$lib/types/agentic';
|
||||
import type {
|
||||
ApiChatCompletionToolCall,
|
||||
ApiChatMessageData,
|
||||
ApiChatMessageContentPart
|
||||
} from '$lib/types/api';
|
||||
import type {
|
||||
ChatMessagePromptProgress,
|
||||
ChatMessageTimings,
|
||||
ChatMessageAgenticTimings,
|
||||
ChatMessageToolCallTiming,
|
||||
ChatMessageAgenticTurnStats
|
||||
} from '$lib/types/chat';
|
||||
import type {
|
||||
DatabaseMessage,
|
||||
DatabaseMessageExtra,
|
||||
DatabaseMessageExtraImageFile
|
||||
} from '$lib/types/database';
|
||||
|
||||
/**
|
||||
* Creates a fresh agentic session with default values.
|
||||
*/
|
||||
function createDefaultSession(): AgenticSession {
|
||||
return {
|
||||
isRunning: false,
|
||||
|
|
@ -51,56 +66,50 @@ function createDefaultSession(): AgenticSession {
|
|||
};
|
||||
}
|
||||
|
||||
function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (
|
||||
message.role === MessageRole.ASSISTANT &&
|
||||
message.tool_calls &&
|
||||
message.tool_calls.length > 0
|
||||
) {
|
||||
return {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: message.content,
|
||||
tool_calls: message.tool_calls.map((call, index) => ({
|
||||
id: call.id ?? `call_${index}`,
|
||||
type: (call.type as 'function') ?? 'function',
|
||||
function: { name: call.function?.name ?? '', arguments: call.function?.arguments ?? '' }
|
||||
}))
|
||||
} satisfies AgenticMessage;
|
||||
}
|
||||
if (message.role === MessageRole.TOOL && message.tool_call_id) {
|
||||
return {
|
||||
role: MessageRole.TOOL,
|
||||
tool_call_id: message.tool_call_id,
|
||||
content: typeof message.content === 'string' ? message.content : ''
|
||||
} satisfies AgenticMessage;
|
||||
}
|
||||
return {
|
||||
role: message.role as MessageRole.SYSTEM | MessageRole.USER,
|
||||
content: message.content
|
||||
} satisfies AgenticMessage;
|
||||
});
|
||||
}
|
||||
|
||||
class AgenticStore {
|
||||
/**
|
||||
* Per-conversation agentic sessions.
|
||||
* Key is conversationId, value is the session state.
|
||||
*/
|
||||
private _sessions = $state<Map<string, AgenticSession>>(new Map());
|
||||
|
||||
/** Reference to the client */
|
||||
private _client = agenticClient;
|
||||
|
||||
private get client() {
|
||||
return this._client;
|
||||
}
|
||||
|
||||
/** Check if store is ready (client initialized) */
|
||||
get isReady(): boolean {
|
||||
return this._initialized;
|
||||
return true;
|
||||
}
|
||||
get isAnyRunning(): boolean {
|
||||
for (const session of this._sessions.values()) {
|
||||
if (session.isRunning) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the store by wiring up to the client.
|
||||
* Must be called once after app startup.
|
||||
*/
|
||||
init(): void {
|
||||
if (!browser) return;
|
||||
if (this._initialized) return; // Already initialized
|
||||
|
||||
agenticClient.setStoreCallbacks({
|
||||
setRunning: (convId, running) => this.updateSession(convId, { isRunning: running }),
|
||||
setCurrentTurn: (convId, turn) => this.updateSession(convId, { currentTurn: turn }),
|
||||
setTotalToolCalls: (convId, count) => this.updateSession(convId, { totalToolCalls: count }),
|
||||
setLastError: (convId, error) => this.updateSession(convId, { lastError: error }),
|
||||
setStreamingToolCall: (convId, tc) => this.updateSession(convId, { streamingToolCall: tc }),
|
||||
clearStreamingToolCall: (convId) => this.updateSession(convId, { streamingToolCall: null })
|
||||
});
|
||||
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Session Management
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get session for a conversation, creating if needed.
|
||||
*/
|
||||
getSession(conversationId: string): AgenticSession {
|
||||
let session = this._sessions.get(conversationId);
|
||||
if (!session) {
|
||||
|
|
@ -109,167 +118,535 @@ class AgenticStore {
|
|||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session state for a conversation.
|
||||
*/
|
||||
private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
|
||||
const session = this.getSession(conversationId);
|
||||
const updated = { ...session, ...update };
|
||||
this._sessions.set(conversationId, updated);
|
||||
this._sessions.set(conversationId, { ...session, ...update });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session for a conversation.
|
||||
*/
|
||||
clearSession(conversationId: string): void {
|
||||
this._sessions.delete(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions (conversations with running agentic flows).
|
||||
*/
|
||||
getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> {
|
||||
const active: Array<{ conversationId: string; session: AgenticSession }> = [];
|
||||
for (const [conversationId, session] of this._sessions.entries()) {
|
||||
if (session.isRunning) {
|
||||
active.push({ conversationId, session });
|
||||
}
|
||||
if (session.isRunning) active.push({ conversationId, session });
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Convenience Getters (for current/active conversation)
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if any agentic flow is running (global).
|
||||
*/
|
||||
get isAnyRunning(): boolean {
|
||||
for (const session of this._sessions.values()) {
|
||||
if (session.isRunning) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get running state for a specific conversation.
|
||||
*/
|
||||
isRunning(conversationId: string): boolean {
|
||||
return this.getSession(conversationId).isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current turn for a specific conversation.
|
||||
*/
|
||||
currentTurn(conversationId: string): number {
|
||||
return this.getSession(conversationId).currentTurn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total tool calls for a specific conversation.
|
||||
*/
|
||||
totalToolCalls(conversationId: string): number {
|
||||
return this.getSession(conversationId).totalToolCalls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last error for a specific conversation.
|
||||
*/
|
||||
lastError(conversationId: string): Error | null {
|
||||
return this.getSession(conversationId).lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming tool call for a specific conversation.
|
||||
*/
|
||||
streamingToolCall(conversationId: string): { name: string; arguments: string } | null {
|
||||
return this.getSession(conversationId).streamingToolCall;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Agentic Flow Execution
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Run the agentic orchestration loop with MCP tools.
|
||||
* Delegates to AgenticClient.
|
||||
*/
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
if (!this.client) {
|
||||
throw new Error('AgenticStore not initialized. Call init() first.');
|
||||
}
|
||||
return this.client.runAgenticFlow(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state for a conversation.
|
||||
*/
|
||||
clearError(conversationId: string): void {
|
||||
this.updateSession(conversationId, { lastError: null });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Configuration
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the current agentic configuration.
|
||||
* Automatically disables agentic mode if no MCP servers are configured.
|
||||
* @param settings - Global settings configuration
|
||||
* @param perChatOverrides - Optional per-chat MCP server overrides
|
||||
*/
|
||||
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
|
||||
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
|
||||
const maxToolPreviewLines =
|
||||
Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines;
|
||||
|
||||
return {
|
||||
enabled: mcpStore.hasEnabledServers(perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
maxTurns,
|
||||
maxToolPreviewLines
|
||||
};
|
||||
}
|
||||
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
|
||||
const {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onError,
|
||||
onTimings
|
||||
} = callbacks;
|
||||
|
||||
const agenticConfig = this.getConfig(config(), perChatOverrides);
|
||||
if (!agenticConfig.enabled) return { handled: false };
|
||||
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
if (!initialized) {
|
||||
console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
const tools = mcpStore.getToolDefinitionsForLLM();
|
||||
if (tools.length === 0) {
|
||||
console.log('[AgenticStore] No tools available, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`);
|
||||
|
||||
const normalizedMessages: ApiChatMessageData[] = messages
|
||||
.map((msg) => {
|
||||
if ('id' in msg && 'convId' in msg && 'timestamp' in msg)
|
||||
return ChatService.convertDbMessageToApiChatMessageData(
|
||||
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
||||
);
|
||||
return msg as ApiChatMessageData;
|
||||
})
|
||||
.filter((msg) => {
|
||||
if (msg.role === MessageRole.SYSTEM) {
|
||||
const content = typeof msg.content === 'string' ? msg.content : '';
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
this.updateSession(conversationId, {
|
||||
isRunning: true,
|
||||
currentTurn: 0,
|
||||
totalToolCalls: 0,
|
||||
lastError: null
|
||||
});
|
||||
mcpStore.acquireConnection();
|
||||
|
||||
try {
|
||||
await this.executeAgenticLoop({
|
||||
conversationId,
|
||||
messages: normalizedMessages,
|
||||
options,
|
||||
tools,
|
||||
agenticConfig,
|
||||
callbacks: {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onError,
|
||||
onTimings
|
||||
},
|
||||
signal
|
||||
});
|
||||
return { handled: true };
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
this.updateSession(conversationId, { lastError: normalizedError });
|
||||
onError?.(normalizedError);
|
||||
return { handled: true, error: normalizedError };
|
||||
} finally {
|
||||
this.updateSession(conversationId, { isRunning: false });
|
||||
await mcpStore
|
||||
.releaseConnection()
|
||||
.catch((err: unknown) =>
|
||||
console.warn('[AgenticStore] Failed to release MCP connection:', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAgenticLoop(params: {
|
||||
conversationId: string;
|
||||
messages: ApiChatMessageData[];
|
||||
options: AgenticFlowOptions;
|
||||
tools: ReturnType<typeof mcpStore.getToolDefinitionsForLLM>;
|
||||
agenticConfig: AgenticConfig;
|
||||
callbacks: AgenticFlowCallbacks;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { conversationId, messages, options, tools, agenticConfig, callbacks, signal } = params;
|
||||
const {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onTimings
|
||||
} = callbacks;
|
||||
|
||||
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
|
||||
const allToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let capturedTimings: ChatMessageTimings | undefined;
|
||||
|
||||
const agenticTimings: ChatMessageAgenticTimings = {
|
||||
turns: 0,
|
||||
toolCallsCount: 0,
|
||||
toolsMs: 0,
|
||||
toolCalls: [],
|
||||
perTurn: [],
|
||||
llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }
|
||||
};
|
||||
const maxTurns = agenticConfig.maxTurns;
|
||||
const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
|
||||
|
||||
for (let turn = 0; turn < maxTurns; turn++) {
|
||||
this.updateSession(conversationId, { currentTurn: turn + 1 });
|
||||
agenticTimings.turns = turn + 1;
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let turnContent = '';
|
||||
let turnToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let lastStreamingToolCallName = '';
|
||||
let lastStreamingToolCallArgsLength = 0;
|
||||
const emittedToolCallStates = new SvelteMap<
|
||||
number,
|
||||
{ emittedOnce: boolean; lastArgs: string }
|
||||
>();
|
||||
let turnTimings: ChatMessageTimings | undefined;
|
||||
|
||||
const turnStats: ChatMessageAgenticTurnStats = {
|
||||
turn: turn + 1,
|
||||
llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 },
|
||||
toolCalls: [],
|
||||
toolsMs: 0
|
||||
};
|
||||
|
||||
try {
|
||||
await ChatService.sendMessage(
|
||||
sessionMessages as ApiChatMessageData[],
|
||||
{
|
||||
...options,
|
||||
stream: true,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
onChunk: (chunk: string) => {
|
||||
turnContent += chunk;
|
||||
onChunk?.(chunk);
|
||||
},
|
||||
onReasoningChunk,
|
||||
onToolCallChunk: (serialized: string) => {
|
||||
try {
|
||||
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
|
||||
for (let i = 0; i < turnToolCalls.length; i++) {
|
||||
const toolCall = turnToolCalls[i];
|
||||
const toolName = toolCall.function?.name ?? '';
|
||||
const toolArgs = toolCall.function?.arguments ?? '';
|
||||
const state = emittedToolCallStates.get(i) || {
|
||||
emittedOnce: false,
|
||||
lastArgs: ''
|
||||
};
|
||||
if (!state.emittedOnce) {
|
||||
const output = `\n\n<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:${toolName}>>>\n<<<TOOL_ARGS_START>>>\n${toolArgs}`;
|
||||
onChunk?.(output);
|
||||
state.emittedOnce = true;
|
||||
state.lastArgs = toolArgs;
|
||||
emittedToolCallStates.set(i, state);
|
||||
} else if (toolArgs.length > state.lastArgs.length) {
|
||||
onChunk?.(toolArgs.slice(state.lastArgs.length));
|
||||
state.lastArgs = toolArgs;
|
||||
emittedToolCallStates.set(i, state);
|
||||
}
|
||||
}
|
||||
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
|
||||
const name = turnToolCalls[0].function.name || '';
|
||||
const args = turnToolCalls[0].function.arguments || '';
|
||||
const argsLengthBucket = Math.floor(args.length / 100);
|
||||
if (
|
||||
name !== lastStreamingToolCallName ||
|
||||
argsLengthBucket !== lastStreamingToolCallArgsLength
|
||||
) {
|
||||
lastStreamingToolCallName = name;
|
||||
lastStreamingToolCallArgsLength = argsLengthBucket;
|
||||
this.updateSession(conversationId, {
|
||||
streamingToolCall: { name, arguments: args }
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Ignore parse errors during streaming */
|
||||
}
|
||||
},
|
||||
onModel,
|
||||
onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => {
|
||||
onTimings?.(timings, progress);
|
||||
if (timings) {
|
||||
capturedTimings = timings;
|
||||
turnTimings = timings;
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
/* Completion handled after sendMessage resolves */
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
signal
|
||||
);
|
||||
|
||||
this.updateSession(conversationId, { streamingToolCall: null });
|
||||
|
||||
if (turnTimings) {
|
||||
agenticTimings.llm.predicted_n += turnTimings.predicted_n || 0;
|
||||
agenticTimings.llm.predicted_ms += turnTimings.predicted_ms || 0;
|
||||
agenticTimings.llm.prompt_n += turnTimings.prompt_n || 0;
|
||||
agenticTimings.llm.prompt_ms += turnTimings.prompt_ms || 0;
|
||||
turnStats.llm.predicted_n = turnTimings.predicted_n || 0;
|
||||
turnStats.llm.predicted_ms = turnTimings.predicted_ms || 0;
|
||||
turnStats.llm.prompt_n = turnTimings.prompt_n || 0;
|
||||
turnStats.llm.prompt_ms = turnTimings.prompt_ms || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
|
||||
onChunk?.(`\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`);
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
throw normalizedError;
|
||||
}
|
||||
|
||||
if (turnToolCalls.length === 0) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
|
||||
if (normalizedCalls.length === 0) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const call of normalizedCalls) {
|
||||
allToolCalls.push({
|
||||
id: call.id,
|
||||
type: call.type,
|
||||
function: call.function ? { ...call.function } : undefined
|
||||
});
|
||||
}
|
||||
this.updateSession(conversationId, { totalToolCalls: allToolCalls.length });
|
||||
onToolCallChunk?.(JSON.stringify(allToolCalls));
|
||||
|
||||
sessionMessages.push({
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: turnContent || undefined,
|
||||
tool_calls: normalizedCalls
|
||||
});
|
||||
|
||||
for (const toolCall of normalizedCalls) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const toolStartTime = performance.now();
|
||||
const mcpCall: MCPToolCall = {
|
||||
id: toolCall.id,
|
||||
function: { name: toolCall.function.name, arguments: toolCall.function.arguments }
|
||||
};
|
||||
|
||||
let result: string;
|
||||
let toolSuccess = true;
|
||||
|
||||
try {
|
||||
const executionResult = await mcpStore.executeTool(mcpCall, signal);
|
||||
result = executionResult.content;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
toolSuccess = false;
|
||||
}
|
||||
|
||||
const toolDurationMs = performance.now() - toolStartTime;
|
||||
const toolTiming: ChatMessageToolCallTiming = {
|
||||
name: toolCall.function.name,
|
||||
duration_ms: Math.round(toolDurationMs),
|
||||
success: toolSuccess
|
||||
};
|
||||
|
||||
agenticTimings.toolCalls!.push(toolTiming);
|
||||
agenticTimings.toolCallsCount++;
|
||||
agenticTimings.toolsMs += Math.round(toolDurationMs);
|
||||
turnStats.toolCalls.push(toolTiming);
|
||||
turnStats.toolsMs += Math.round(toolDurationMs);
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { cleanedResult, attachments } = this.extractBase64Attachments(result);
|
||||
if (attachments.length > 0) onAttachments?.(attachments);
|
||||
|
||||
this.emitToolCallResult(cleanedResult, maxToolPreviewLines, onChunk);
|
||||
|
||||
const contentParts: ApiChatMessageContentPart[] = [{ type: 'text', text: cleanedResult }];
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.type === AttachmentType.IMAGE) {
|
||||
contentParts.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: (attachment as DatabaseMessageExtraImageFile).base64Url }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sessionMessages.push({
|
||||
role: MessageRole.TOOL,
|
||||
tool_call_id: toolCall.id,
|
||||
content: contentParts.length === 1 ? cleanedResult : contentParts
|
||||
});
|
||||
}
|
||||
|
||||
if (turnStats.toolCalls.length > 0) agenticTimings.perTurn!.push(turnStats);
|
||||
}
|
||||
|
||||
onChunk?.('\n\n```\nTurn limit reached\n```\n');
|
||||
onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
|
||||
}
|
||||
|
||||
private buildFinalTimings(
|
||||
capturedTimings: ChatMessageTimings | undefined,
|
||||
agenticTimings: ChatMessageAgenticTimings
|
||||
): ChatMessageTimings | undefined {
|
||||
if (agenticTimings.toolCallsCount === 0) return capturedTimings;
|
||||
return {
|
||||
predicted_n: capturedTimings?.predicted_n,
|
||||
predicted_ms: capturedTimings?.predicted_ms,
|
||||
prompt_n: capturedTimings?.prompt_n,
|
||||
prompt_ms: capturedTimings?.prompt_ms,
|
||||
cache_n: capturedTimings?.cache_n,
|
||||
agentic: agenticTimings
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
|
||||
if (!toolCalls) return [];
|
||||
return toolCalls.map((call, index) => ({
|
||||
id: call?.id ?? `tool_${index}`,
|
||||
type: (call?.type as 'function') ?? 'function',
|
||||
function: { name: call?.function?.name ?? '', arguments: call?.function?.arguments ?? '' }
|
||||
}));
|
||||
}
|
||||
|
||||
private emitToolCallResult(
|
||||
result: string,
|
||||
maxLines: number,
|
||||
emit?: (chunk: string) => void
|
||||
): void {
|
||||
if (!emit) return;
|
||||
let output = `\n<<<TOOL_ARGS_END>>>`;
|
||||
const lines = result.split('\n');
|
||||
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
|
||||
output += `\n${trimmedLines.join('\n')}\n<<<AGENTIC_TOOL_CALL_END>>>\n`;
|
||||
emit(output);
|
||||
}
|
||||
|
||||
private extractBase64Attachments(result: string): {
|
||||
cleanedResult: string;
|
||||
attachments: DatabaseMessageExtra[];
|
||||
} {
|
||||
if (!result.trim()) return { cleanedResult: result, attachments: [] };
|
||||
const lines = result.split('\n');
|
||||
const attachments: DatabaseMessageExtra[] = [];
|
||||
let attachmentIndex = 0;
|
||||
|
||||
const cleanedLines = lines.map((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
const match = trimmedLine.match(/^data:([^;]+);base64,([A-Za-z0-9+/]+=*)$/);
|
||||
if (!match) return line;
|
||||
const mimeType = match[1].toLowerCase();
|
||||
const base64Data = match[2];
|
||||
if (!base64Data) return line;
|
||||
|
||||
attachmentIndex += 1;
|
||||
const name = this.buildAttachmentName(mimeType, attachmentIndex);
|
||||
|
||||
if (mimeType.startsWith('image/')) {
|
||||
attachments.push({ type: AttachmentType.IMAGE, name, base64Url: trimmedLine });
|
||||
return `[Attachment saved: ${name}]`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
return { cleanedResult: cleanedLines.join('\n'), attachments };
|
||||
}
|
||||
|
||||
private buildAttachmentName(mimeType: string, index: number): string {
|
||||
const extensionMap: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp'
|
||||
};
|
||||
const extension = extensionMap[mimeType] ?? 'img';
|
||||
return `mcp-attachment-${Date.now()}-${index}.${extension}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const agenticStore = new AgenticStore();
|
||||
|
||||
// Auto-initialize in browser
|
||||
if (browser) {
|
||||
agenticStore.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper functions for reactive access in components.
|
||||
* These require conversationId parameter for per-conversation state.
|
||||
*/
|
||||
export function agenticIsRunning(conversationId: string) {
|
||||
return agenticStore.isRunning(conversationId);
|
||||
}
|
||||
|
||||
export function agenticCurrentTurn(conversationId: string) {
|
||||
return agenticStore.currentTurn(conversationId);
|
||||
}
|
||||
|
||||
export function agenticTotalToolCalls(conversationId: string) {
|
||||
return agenticStore.totalToolCalls(conversationId);
|
||||
}
|
||||
|
||||
export function agenticLastError(conversationId: string) {
|
||||
return agenticStore.lastError(conversationId);
|
||||
}
|
||||
|
||||
export function agenticStreamingToolCall(conversationId: string) {
|
||||
return agenticStore.streamingToolCall(conversationId);
|
||||
}
|
||||
|
||||
export function agenticIsAnyRunning() {
|
||||
return agenticStore.isAnyRunning;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,30 +1,42 @@
|
|||
/**
|
||||
* conversationsStore - Reactive State Store for Conversations
|
||||
*
|
||||
* This store contains ONLY reactive state ($state, $derived).
|
||||
* All business logic is delegated to ConversationsClient.
|
||||
* Manages conversation lifecycle, persistence, navigation, and MCP server overrides.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ConversationsClient**: Business logic facade (CRUD, navigation, import/export)
|
||||
* - **DatabaseService**: Stateless IndexedDB layer
|
||||
* - **conversationsStore** (this): Reactive state for UI components
|
||||
* - **conversationsStore** (this): Reactive state + business logic
|
||||
* - **chatStore**: Chat-specific state (streaming, loading)
|
||||
*
|
||||
* **Responsibilities:**
|
||||
* - Hold reactive state for UI binding
|
||||
* - Provide getters for computed values
|
||||
* - Expose setters for ConversationsClient to update state
|
||||
* - Forward method calls to ConversationsClient
|
||||
* **Key Responsibilities:**
|
||||
* - Conversation CRUD (create, load, delete)
|
||||
* - Message management and tree navigation
|
||||
* - MCP server per-chat overrides
|
||||
* - Import/Export functionality
|
||||
* - Title management with confirmation
|
||||
*
|
||||
* @see ConversationsClient in clients/ for business logic
|
||||
* @see DatabaseService in services/database.ts for IndexedDB operations
|
||||
*/
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { setActiveConversationId } from '$lib/stores/shared';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
class ConversationsStore {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* State
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/** List of all conversations */
|
||||
conversations = $state<DatabaseConversation[]>([]);
|
||||
|
||||
|
|
@ -43,59 +55,63 @@ class ConversationsStore {
|
|||
/** Callback for title update confirmation dialog */
|
||||
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
||||
|
||||
/** Reference to the client (lazy loaded to avoid circular dependency) */
|
||||
private _client: typeof import('$lib/clients/conversations.client').conversationsClient | null =
|
||||
null;
|
||||
|
||||
private get client() {
|
||||
return this._client;
|
||||
}
|
||||
|
||||
/** Check if store is ready (client initialized) */
|
||||
get isReady(): boolean {
|
||||
return this._client !== null;
|
||||
}
|
||||
/**
|
||||
* Callback for updating message content in chatStore.
|
||||
* Registered by chatStore to enable cross-store updates without circular dependency.
|
||||
*/
|
||||
private messageUpdateCallback:
|
||||
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
|
||||
| null = null;
|
||||
|
||||
/**
|
||||
* Initialize the store by wiring up to the client.
|
||||
*
|
||||
*
|
||||
* Lifecycle
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize the store by loading conversations from database.
|
||||
* Must be called once after app startup.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (!browser) return;
|
||||
if (this._client) return;
|
||||
if (this.isInitialized) return;
|
||||
|
||||
const { conversationsClient } = await import('$lib/clients/conversations.client');
|
||||
this._client = conversationsClient;
|
||||
|
||||
// Register message update callback with chatStore to avoid circular dependency
|
||||
chatStore.registerMessageUpdateCallback((messageId, updates) => {
|
||||
const idx = this.findMessageIndex(messageId);
|
||||
if (idx !== -1) {
|
||||
this.updateMessageAtIndex(idx, updates);
|
||||
}
|
||||
});
|
||||
|
||||
conversationsClient.setStoreCallbacks({
|
||||
getConversations: () => this.conversations,
|
||||
setConversations: (conversations) => (this.conversations = conversations),
|
||||
getActiveConversation: () => this.activeConversation,
|
||||
setActiveConversation: (conversation) => {
|
||||
this.activeConversation = conversation;
|
||||
// Update shared state for chatStore to use without circular dependency
|
||||
setActiveConversationId(conversation?.id ?? null);
|
||||
},
|
||||
getActiveMessages: () => this.activeMessages,
|
||||
setActiveMessages: (messages) => (this.activeMessages = messages),
|
||||
updateActiveMessages: (updater) => (this.activeMessages = updater(this.activeMessages)),
|
||||
setInitialized: (initialized) => (this.isInitialized = initialized),
|
||||
getPendingMcpServerOverrides: () => this.pendingMcpServerOverrides,
|
||||
setPendingMcpServerOverrides: (overrides) => (this.pendingMcpServerOverrides = overrides),
|
||||
getTitleUpdateConfirmationCallback: () => this.titleUpdateConfirmationCallback
|
||||
});
|
||||
|
||||
await conversationsClient.initialize();
|
||||
try {
|
||||
await this.loadConversations();
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize conversations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for init() for backward compatibility.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
return this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for message updates from other stores.
|
||||
* Called by chatStore during initialization.
|
||||
*/
|
||||
registerMessageUpdateCallback(
|
||||
callback: (messageId: string, updates: Partial<DatabaseMessage>) => void
|
||||
): void {
|
||||
this.messageUpdateCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Message Array Operations
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adds a message to the active messages array
|
||||
*/
|
||||
|
|
@ -145,84 +161,364 @@ class ConversationsStore {
|
|||
this.titleUpdateConfirmationCallback = callback;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.initialize();
|
||||
}
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Conversation CRUD
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Loads all conversations from the database
|
||||
*/
|
||||
async loadConversations(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.loadConversations();
|
||||
const conversations = await DatabaseService.getAllConversations();
|
||||
this.conversations = conversations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new conversation and navigates to it
|
||||
* @param name - Optional name for the conversation
|
||||
* @returns The ID of the created conversation
|
||||
*/
|
||||
async createConversation(name?: string): Promise<string> {
|
||||
if (!this.client) throw new Error('ConversationsStore not initialized');
|
||||
return this.client.createConversation(name);
|
||||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||
const conversation = await DatabaseService.createConversation(conversationName);
|
||||
|
||||
if (this.pendingMcpServerOverrides.length > 0) {
|
||||
// Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
|
||||
const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}));
|
||||
conversation.mcpServerOverrides = plainOverrides;
|
||||
await DatabaseService.updateConversation(conversation.id, {
|
||||
mcpServerOverrides: plainOverrides
|
||||
});
|
||||
this.pendingMcpServerOverrides = [];
|
||||
}
|
||||
|
||||
this.conversations = [conversation, ...this.conversations];
|
||||
this.activeConversation = conversation;
|
||||
this.activeMessages = [];
|
||||
|
||||
await goto(`#/chat/${conversation.id}`);
|
||||
|
||||
return conversation.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a specific conversation and its messages
|
||||
* @param convId - The conversation ID to load
|
||||
* @returns True if conversation was loaded successfully
|
||||
*/
|
||||
async loadConversation(convId: string): Promise<boolean> {
|
||||
if (!this.client) return false;
|
||||
return this.client.loadConversation(convId);
|
||||
try {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
|
||||
if (!conversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pendingMcpServerOverrides = [];
|
||||
this.activeConversation = conversation;
|
||||
|
||||
if (conversation.currNode) {
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
const filteredMessages = filterByLeafNodeId(
|
||||
allMessages,
|
||||
conversation.currNode,
|
||||
false
|
||||
) as DatabaseMessage[];
|
||||
this.activeMessages = filteredMessages;
|
||||
} else {
|
||||
const messages = await DatabaseService.getConversationMessages(convId);
|
||||
this.activeMessages = messages;
|
||||
}
|
||||
|
||||
// Run MCP health checks for enabled servers in this conversation
|
||||
this.runMcpHealthChecksForConversation(conversation.mcpServerOverrides);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs MCP health checks for servers enabled in a conversation.
|
||||
* Runs asynchronously in the background without blocking conversation loading.
|
||||
*/
|
||||
private runMcpHealthChecksForConversation(mcpServerOverrides?: McpServerOverride[]): void {
|
||||
if (!mcpServerOverrides?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledServers = mcpStore.getEnabledServersForConversation(mcpServerOverrides);
|
||||
|
||||
if (enabledServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[conversationsStore] Running health checks for ${enabledServers.length} MCP server(s)`
|
||||
);
|
||||
|
||||
// Run health checks in background (don't await)
|
||||
mcpStore.runHealthChecksForServers(enabledServers).catch((error) => {
|
||||
console.warn('[conversationsStore] MCP health checks failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active conversation and messages.
|
||||
*/
|
||||
clearActiveConversation(): void {
|
||||
if (!this.client) return;
|
||||
this.client.clearActiveConversation();
|
||||
this.activeConversation = null;
|
||||
this.activeMessages = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a conversation and all its messages
|
||||
* @param convId - The conversation ID to delete
|
||||
*/
|
||||
async deleteConversation(convId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.deleteConversation(convId);
|
||||
try {
|
||||
await DatabaseService.deleteConversation(convId);
|
||||
|
||||
this.conversations = this.conversations.filter((c) => c.id !== convId);
|
||||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
this.clearActiveConversation();
|
||||
await goto(`?new_chat=true#/`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all conversations and their messages
|
||||
*/
|
||||
async deleteAll(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.deleteAll();
|
||||
try {
|
||||
const allConversations = await DatabaseService.getAllConversations();
|
||||
|
||||
for (const conv of allConversations) {
|
||||
await DatabaseService.deleteConversation(conv.id);
|
||||
}
|
||||
|
||||
this.clearActiveConversation();
|
||||
this.conversations = [];
|
||||
|
||||
toast.success('All conversations deleted');
|
||||
|
||||
await goto(`?new_chat=true#/`);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete all conversations:', error);
|
||||
toast.error('Failed to delete conversations');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Message Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Refreshes active messages based on currNode after branch navigation.
|
||||
*/
|
||||
async refreshActiveMessages(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.refreshActiveMessages();
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
this.activeMessages = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const leafNodeId =
|
||||
this.activeConversation.currNode ||
|
||||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
|
||||
|
||||
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
||||
|
||||
this.activeMessages = currentPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all messages for a specific conversation
|
||||
* @param convId - The conversation ID
|
||||
* @returns Array of messages
|
||||
*/
|
||||
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||
if (!this.client) return [];
|
||||
return this.client.getConversationMessages(convId);
|
||||
return await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Title Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Updates the name of a conversation.
|
||||
* @param convId - The conversation ID to update
|
||||
* @param name - The new name for the conversation
|
||||
*/
|
||||
async updateConversationName(convId: string, name: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.updateConversationName(convId, name);
|
||||
try {
|
||||
await DatabaseService.updateConversation(convId, { name });
|
||||
|
||||
const convIndex = this.conversations.findIndex((c) => c.id === convId);
|
||||
|
||||
if (convIndex !== -1) {
|
||||
this.conversations[convIndex].name = name;
|
||||
this.conversations = [...this.conversations];
|
||||
}
|
||||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
this.activeConversation = { ...this.activeConversation, name };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation name:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation title with optional confirmation dialog based on settings
|
||||
* @param convId - The conversation ID to update
|
||||
* @param newTitle - The new title content
|
||||
* @returns True if title was updated, false if cancelled
|
||||
*/
|
||||
async updateConversationTitleWithConfirmation(
|
||||
convId: string,
|
||||
newTitle: string
|
||||
): Promise<boolean> {
|
||||
if (!this.client) return false;
|
||||
return this.client.updateConversationTitleWithConfirmation(convId, newTitle);
|
||||
}
|
||||
try {
|
||||
const currentConfig = config();
|
||||
|
||||
updateConversationTimestamp(): void {
|
||||
if (!this.client) return;
|
||||
this.client.updateConversationTimestamp();
|
||||
}
|
||||
if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return false;
|
||||
|
||||
async updateCurrentNode(nodeId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.updateCurrentNode(nodeId);
|
||||
}
|
||||
const shouldUpdate = await this.titleUpdateConfirmationCallback(
|
||||
conversation.name,
|
||||
newTitle
|
||||
);
|
||||
if (!shouldUpdate) return false;
|
||||
}
|
||||
|
||||
async navigateToSibling(siblingId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.navigateToSibling(siblingId);
|
||||
}
|
||||
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
if (!this.client) {
|
||||
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
await this.updateConversationName(convId, newTitle);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation title with confirmation:', error);
|
||||
return false;
|
||||
}
|
||||
return this.client.getMcpServerOverride(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation lastModified timestamp and moves it to top of list
|
||||
*/
|
||||
updateConversationTimestamp(): void {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||
|
||||
if (chatIndex !== -1) {
|
||||
this.conversations[chatIndex].lastModified = Date.now();
|
||||
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
|
||||
this.conversations = [updatedConv, ...this.conversations];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current node of the active conversation
|
||||
* @param nodeId - The new current node ID
|
||||
*/
|
||||
async updateCurrentNode(nodeId: string): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
|
||||
this.activeConversation = { ...this.activeConversation, currNode: nodeId };
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Branch Navigation
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Navigates to a specific sibling branch by updating currNode and refreshing messages.
|
||||
* @param siblingId - The sibling message ID to navigate to
|
||||
*/
|
||||
async navigateToSibling(siblingId: string): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
||||
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
||||
const currentFirstUserMessage = this.activeMessages.find(
|
||||
(m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
|
||||
);
|
||||
|
||||
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
|
||||
this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
|
||||
await this.refreshActiveMessages();
|
||||
|
||||
if (rootMessage && this.activeMessages.length > 0) {
|
||||
const newFirstUserMessage = this.activeMessages.find(
|
||||
(m) => m.role === MessageRole.USER && m.parent === rootMessage.id
|
||||
);
|
||||
|
||||
if (
|
||||
newFirstUserMessage &&
|
||||
newFirstUserMessage.content.trim() &&
|
||||
(!currentFirstUserMessage ||
|
||||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
|
||||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
|
||||
) {
|
||||
await this.updateConversationTitleWithConfirmation(
|
||||
this.activeConversation.id,
|
||||
newFirstUserMessage.content.trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* MCP Server Overrides
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets MCP server override for a specific server in the active conversation.
|
||||
* Falls back to pending overrides if no active conversation exists.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns The override if set, undefined if using global setting
|
||||
*/
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
if (this.activeConversation) {
|
||||
return this.activeConversation.mcpServerOverrides?.find(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
}
|
||||
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -233,61 +529,279 @@ class ConversationsStore {
|
|||
if (this.activeConversation?.mcpServerOverrides) {
|
||||
return this.activeConversation.mcpServerOverrides;
|
||||
}
|
||||
|
||||
return this.pendingMcpServerOverrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an MCP server is enabled for the active conversation.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns True if server is enabled for this conversation
|
||||
*/
|
||||
isMcpServerEnabledForChat(serverId: string): boolean {
|
||||
if (!this.client) {
|
||||
const override = this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
return override?.enabled ?? false;
|
||||
}
|
||||
return this.client.isMcpServerEnabledForChat(serverId);
|
||||
const override = this.getMcpServerOverride(serverId);
|
||||
return override?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes MCP server override for the active conversation.
|
||||
* If no conversation exists, stores as pending override.
|
||||
* @param serverId - The server ID to override
|
||||
* @param enabled - The enabled state, or undefined to remove override
|
||||
*/
|
||||
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.setMcpServerOverride(serverId, enabled);
|
||||
}
|
||||
|
||||
async toggleMcpServerForChat(serverId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.toggleMcpServerForChat(serverId);
|
||||
}
|
||||
|
||||
async removeMcpServerOverride(serverId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.removeMcpServerOverride(serverId);
|
||||
}
|
||||
|
||||
clearPendingMcpServerOverrides(): void {
|
||||
if (!this.client) {
|
||||
this.pendingMcpServerOverrides = [];
|
||||
if (!this.activeConversation) {
|
||||
this.setPendingMcpServerOverride(serverId, enabled);
|
||||
return;
|
||||
}
|
||||
this.client.clearPendingMcpServerOverrides();
|
||||
|
||||
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
|
||||
const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map(
|
||||
(o: McpServerOverride) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
})
|
||||
);
|
||||
let newOverrides: McpServerOverride[];
|
||||
|
||||
if (enabled === undefined) {
|
||||
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
|
||||
} else {
|
||||
const existingIndex = currentOverrides.findIndex(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
newOverrides = [...currentOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
} else {
|
||||
newOverrides = [...currentOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
|
||||
await DatabaseService.updateConversation(this.activeConversation.id, {
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
});
|
||||
|
||||
this.activeConversation = {
|
||||
...this.activeConversation,
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
};
|
||||
|
||||
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||
if (convIndex !== -1) {
|
||||
this.conversations[convIndex].mcpServerOverrides =
|
||||
newOverrides.length > 0 ? newOverrides : undefined;
|
||||
this.conversations = [...this.conversations];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes a pending MCP server override (for new conversations).
|
||||
*/
|
||||
private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
|
||||
if (enabled === undefined) {
|
||||
this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter(
|
||||
(o) => o.serverId !== serverId
|
||||
);
|
||||
} else {
|
||||
const existingIndex = this.pendingMcpServerOverrides.findIndex(
|
||||
(o) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const newOverrides = [...this.pendingMcpServerOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
this.pendingMcpServerOverrides = newOverrides;
|
||||
} else {
|
||||
this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles MCP server enabled state for the active conversation.
|
||||
* @param serverId - The server ID to toggle
|
||||
*/
|
||||
async toggleMcpServerForChat(serverId: string): Promise<void> {
|
||||
const currentEnabled = this.isMcpServerEnabledForChat(serverId);
|
||||
await this.setMcpServerOverride(serverId, !currentEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes MCP server override for the active conversation.
|
||||
* @param serverId - The server ID to remove override for
|
||||
*/
|
||||
async removeMcpServerOverride(serverId: string): Promise<void> {
|
||||
await this.setMcpServerOverride(serverId, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all pending MCP server overrides.
|
||||
*/
|
||||
clearPendingMcpServerOverrides(): void {
|
||||
this.pendingMcpServerOverrides = [];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import & Export
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Downloads a conversation as JSON file.
|
||||
* @param convId - The conversation ID to download
|
||||
*/
|
||||
async downloadConversation(convId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.downloadConversation(convId);
|
||||
let conversation: DatabaseConversation | null;
|
||||
let messages: DatabaseMessage[];
|
||||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
conversation = this.activeConversation;
|
||||
messages = this.activeMessages;
|
||||
} else {
|
||||
conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return;
|
||||
messages = await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
this.triggerDownload({ conv: conversation, messages });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all conversations with their messages as a JSON file
|
||||
* @returns The list of exported conversations
|
||||
*/
|
||||
async exportAllConversations(): Promise<DatabaseConversation[]> {
|
||||
if (!this.client) return [];
|
||||
return this.client.exportAllConversations();
|
||||
const allConversations = await DatabaseService.getAllConversations();
|
||||
|
||||
if (allConversations.length === 0) {
|
||||
throw new Error('No conversations to export');
|
||||
}
|
||||
|
||||
const allData = await Promise.all(
|
||||
allConversations.map(async (conv) => {
|
||||
const messages = await DatabaseService.getConversationMessages(conv.id);
|
||||
return { conv, messages };
|
||||
})
|
||||
);
|
||||
|
||||
const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`All conversations (${allConversations.length}) prepared for download`);
|
||||
|
||||
return allConversations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports conversations from a JSON file
|
||||
* Opens file picker and processes the selected file
|
||||
* @returns The list of imported conversations
|
||||
*/
|
||||
async importConversations(): Promise<DatabaseConversation[]> {
|
||||
if (!this.client) return [];
|
||||
return this.client.importConversations();
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement)?.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
reject(new Error('No file selected'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsedData = JSON.parse(text);
|
||||
let importedData: ExportedConversations;
|
||||
|
||||
if (Array.isArray(parsedData)) {
|
||||
importedData = parsedData;
|
||||
} else if (
|
||||
parsedData &&
|
||||
typeof parsedData === 'object' &&
|
||||
'conv' in parsedData &&
|
||||
'messages' in parsedData
|
||||
) {
|
||||
importedData = [parsedData];
|
||||
} else {
|
||||
throw new Error('Invalid file format');
|
||||
}
|
||||
|
||||
const result = await DatabaseService.importConversations(importedData);
|
||||
toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
|
||||
|
||||
await this.loadConversations();
|
||||
|
||||
const importedConversations = (
|
||||
Array.isArray(importedData) ? importedData : [importedData]
|
||||
).map((item) => item.conv);
|
||||
|
||||
resolve(importedConversations);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('Failed to import conversations:', err);
|
||||
toast.error('Import failed', { description: message });
|
||||
reject(new Error(`Import failed: ${message}`));
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports conversations from provided data (without file picker)
|
||||
* @param data - Array of conversation data with messages
|
||||
* @returns Import result with counts
|
||||
*/
|
||||
async importConversationsData(
|
||||
data: ExportedConversations
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
if (!this.client) return { imported: 0, skipped: 0 };
|
||||
return this.client.importConversationsData(data);
|
||||
const result = await DatabaseService.importConversations(data);
|
||||
await this.loadConversations();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers file download in browser
|
||||
*/
|
||||
private triggerDownload(data: ExportedConversations, filename?: string): void {
|
||||
const conversation =
|
||||
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
|
||||
|
||||
if (!conversation) {
|
||||
console.error('Invalid data: missing conversation');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationName = conversation.name?.trim() || '';
|
||||
const truncatedSuffix = conversationName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/gi, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.substring(0, 20);
|
||||
const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFilename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,182 @@
|
|||
/**
|
||||
* mcpStore - Reactive State Store for MCP (Model Context Protocol)
|
||||
* mcpStore - Reactive State Store for MCP Operations
|
||||
*
|
||||
* This store contains ONLY reactive state ($state, $derived).
|
||||
* All business logic is delegated to MCPClient.
|
||||
* Implements the "Host" role in MCP architecture, coordinating multiple server
|
||||
* connections and providing a unified interface for tool operations.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **MCPClient**: Business logic facade (lifecycle, tool execution, health checks)
|
||||
* - **MCPService**: Stateless protocol layer (transport, connect, callTool)
|
||||
* - **mcpStore** (this): Reactive state for UI components
|
||||
* - **mcpStore** (this): Reactive state + business logic
|
||||
*
|
||||
* **Responsibilities:**
|
||||
* - Hold reactive state for UI binding
|
||||
* - Provide getters for computed values
|
||||
* - Expose setters for MCPClient to update state
|
||||
* - Forward method calls to MCPClient
|
||||
* **Key Responsibilities:**
|
||||
* - Lifecycle management (initialize, shutdown)
|
||||
* - Multi-server coordination
|
||||
* - Tool name conflict detection and resolution
|
||||
* - OpenAI-compatible tool definition generation
|
||||
* - Automatic tool-to-server routing
|
||||
* - Health checks
|
||||
*
|
||||
* @see MCPClient in clients/mcp/ for business logic
|
||||
* @see MCPService in services/mcp.ts for protocol operations
|
||||
* @see MCPService in services/mcp.service.ts for protocol operations
|
||||
*/
|
||||
|
||||
import { mcpClient, buildMcpClientConfig } from '$lib/clients/mcp.client';
|
||||
import { browser } from '$app/environment';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { parseMcpServerSettings, detectMcpTransportFromUrl } from '$lib/utils';
|
||||
import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums';
|
||||
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
||||
import type {
|
||||
MCPToolCall,
|
||||
OpenAIToolDefinition,
|
||||
ServerStatus,
|
||||
ToolExecutionResult,
|
||||
MCPClientConfig,
|
||||
MCPConnection,
|
||||
HealthCheckParams,
|
||||
ServerCapabilities,
|
||||
ClientCapabilities,
|
||||
MCPCapabilitiesInfo,
|
||||
MCPConnectionLog,
|
||||
MCPPromptInfo,
|
||||
GetPromptResult,
|
||||
Tool,
|
||||
Prompt,
|
||||
HealthCheckState,
|
||||
MCPServerSettingsEntry,
|
||||
MCPPromptInfo,
|
||||
GetPromptResult
|
||||
MCPServerConfig
|
||||
} from '$lib/types';
|
||||
import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { parseMcpServerSettings } from '$lib/utils';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
|
||||
function generateMcpServerId(id: unknown, index: number): string {
|
||||
if (typeof id === 'string' && id.trim()) return id.trim();
|
||||
return `${MCP_SERVER_ID_PREFIX}${index + 1}`;
|
||||
}
|
||||
|
||||
function parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
|
||||
if (!rawServers) return [];
|
||||
let parsed: unknown;
|
||||
if (typeof rawServers === 'string') {
|
||||
const trimmed = rawServers.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
console.warn('[MCP] Failed to parse mcpServers JSON:', error);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
parsed = rawServers;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map((entry, index) => {
|
||||
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
|
||||
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
|
||||
return {
|
||||
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
|
||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||
url,
|
||||
name: (entry as { name?: string })?.name,
|
||||
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
headers: headers || undefined
|
||||
} satisfies MCPServerSettingsEntry;
|
||||
});
|
||||
}
|
||||
|
||||
function buildServerConfig(
|
||||
entry: MCPServerSettingsEntry,
|
||||
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
|
||||
): MCPServerConfig | undefined {
|
||||
if (!entry?.url) return undefined;
|
||||
let headers: Record<string, string> | undefined;
|
||||
if (entry.headers) {
|
||||
try {
|
||||
const parsed = JSON.parse(entry.headers);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
|
||||
headers = parsed as Record<string, string>;
|
||||
} catch {
|
||||
console.warn('[MCP] Failed to parse custom headers JSON:', entry.headers);
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: entry.url,
|
||||
transport: detectMcpTransportFromUrl(entry.url),
|
||||
handshakeTimeoutMs: connectionTimeoutMs,
|
||||
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
|
||||
headers
|
||||
};
|
||||
}
|
||||
|
||||
function checkServerEnabled(
|
||||
server: MCPServerSettingsEntry,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): boolean {
|
||||
if (!server.enabled) return false;
|
||||
if (perChatOverrides) {
|
||||
const override = perChatOverrides.find((o) => o.serverId === server.id);
|
||||
return override?.enabled ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildMcpClientConfigInternal(
|
||||
cfg: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): MCPClientConfig | undefined {
|
||||
const rawServers = parseServerSettings(cfg.mcpServers);
|
||||
if (!rawServers.length) return undefined;
|
||||
const servers: Record<string, MCPServerConfig> = {};
|
||||
for (const [index, entry] of rawServers.entries()) {
|
||||
if (!checkServerEnabled(entry, perChatOverrides)) continue;
|
||||
const normalized = buildServerConfig(entry);
|
||||
if (normalized) servers[generateMcpServerId(entry.id, index)] = normalized;
|
||||
}
|
||||
if (Object.keys(servers).length === 0) return undefined;
|
||||
return {
|
||||
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
|
||||
capabilities: DEFAULT_MCP_CONFIG.capabilities,
|
||||
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
|
||||
requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
|
||||
servers
|
||||
};
|
||||
}
|
||||
|
||||
function buildCapabilitiesInfo(
|
||||
serverCaps?: ServerCapabilities,
|
||||
clientCaps?: ClientCapabilities
|
||||
): MCPCapabilitiesInfo {
|
||||
return {
|
||||
server: {
|
||||
tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined,
|
||||
prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined,
|
||||
resources: serverCaps?.resources
|
||||
? {
|
||||
subscribe: serverCaps.resources.subscribe,
|
||||
listChanged: serverCaps.resources.listChanged
|
||||
}
|
||||
: undefined,
|
||||
logging: !!serverCaps?.logging,
|
||||
completions: !!serverCaps?.completions,
|
||||
tasks: !!serverCaps?.tasks
|
||||
},
|
||||
client: {
|
||||
roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined,
|
||||
sampling: !!clientCaps?.sampling,
|
||||
elicitation: clientCaps?.elicitation
|
||||
? { form: !!clientCaps.elicitation.form, url: !!clientCaps.elicitation.url }
|
||||
: undefined,
|
||||
tasks: !!clientCaps?.tasks
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMcpClientConfig(
|
||||
cfg: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): MCPClientConfig | undefined {
|
||||
return buildMcpClientConfigInternal(cfg, perChatOverrides);
|
||||
}
|
||||
|
||||
class MCPStore {
|
||||
private _isInitializing = $state(false);
|
||||
|
|
@ -39,10 +185,41 @@ class MCPStore {
|
|||
private _connectedServers = $state<string[]>([]);
|
||||
private _healthChecks = $state<Record<string, HealthCheckState>>({});
|
||||
|
||||
/**
|
||||
* Update state from MCPClient
|
||||
*/
|
||||
updateState(state: {
|
||||
private connections = new Map<string, MCPConnection>();
|
||||
private toolsIndex = new Map<string, string>();
|
||||
private configSignature: string | null = null;
|
||||
private initPromise: Promise<boolean> | null = null;
|
||||
private activeFlowCount = 0;
|
||||
|
||||
get isInitializing(): boolean {
|
||||
return this._isInitializing;
|
||||
}
|
||||
get isInitialized(): boolean {
|
||||
return this.connections.size > 0;
|
||||
}
|
||||
get error(): string | null {
|
||||
return this._error;
|
||||
}
|
||||
get toolCount(): number {
|
||||
return this._toolCount;
|
||||
}
|
||||
get connectedServerCount(): number {
|
||||
return this._connectedServers.length;
|
||||
}
|
||||
get connectedServerNames(): string[] {
|
||||
return this._connectedServers;
|
||||
}
|
||||
get isEnabled(): boolean {
|
||||
const mcpConfig = buildMcpClientConfigInternal(config());
|
||||
return (
|
||||
mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
|
||||
);
|
||||
}
|
||||
get availableTools(): string[] {
|
||||
return Array.from(this.toolsIndex.keys());
|
||||
}
|
||||
|
||||
private updateState(state: {
|
||||
isInitializing?: boolean;
|
||||
error?: string | null;
|
||||
toolCount?: number;
|
||||
|
|
@ -54,125 +231,42 @@ class MCPStore {
|
|||
if (state.connectedServers !== undefined) this._connectedServers = state.connectedServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update health check state from MCPClient.
|
||||
*/
|
||||
updateHealthCheck(serverId: string, state: HealthCheckState): void {
|
||||
this._healthChecks = { ...this._healthChecks, [serverId]: state };
|
||||
}
|
||||
|
||||
get isInitializing(): boolean {
|
||||
return this._isInitializing;
|
||||
}
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return mcpClient.isInitialized;
|
||||
}
|
||||
|
||||
get error(): string | null {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
get toolCount(): number {
|
||||
return this._toolCount;
|
||||
}
|
||||
|
||||
get connectedServerCount(): number {
|
||||
return this._connectedServers.length;
|
||||
}
|
||||
|
||||
get connectedServerNames(): string[] {
|
||||
return this._connectedServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MCP is enabled (has configured servers)
|
||||
*/
|
||||
get isEnabled(): boolean {
|
||||
const mcpConfig = buildMcpClientConfig(config());
|
||||
return (
|
||||
mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available tool names
|
||||
*/
|
||||
get availableTools(): string[] {
|
||||
return mcpClient.getToolNames();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health check state for a specific server
|
||||
*/
|
||||
getHealthCheckState(serverId: string): HealthCheckState {
|
||||
return this._healthChecks[serverId] ?? { status: 'idle' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if health check has been performed for a server
|
||||
*/
|
||||
hasHealthCheck(serverId: string): boolean {
|
||||
return serverId in this._healthChecks && this._healthChecks[serverId].status !== 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear health check state for a specific server.
|
||||
*/
|
||||
clearHealthCheck(serverId: string): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [serverId]: _removed, ...rest } = this._healthChecks;
|
||||
this._healthChecks = rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all health check states.
|
||||
*/
|
||||
clearAllHealthChecks(): void {
|
||||
this._healthChecks = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError(): void {
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Server Management (CRUD)
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all configured MCP servers from settings (unsorted).
|
||||
*/
|
||||
getServers(): MCPServerSettingsEntry[] {
|
||||
return parseMcpServerSettings(config().mcpServers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a display label for an MCP server.
|
||||
* Automatically fetches health state from store.
|
||||
*/
|
||||
getServerLabel(server: MCPServerSettingsEntry): string {
|
||||
const healthState = this.getHealthCheckState(server.id);
|
||||
if (healthState?.status === HealthCheckStatus.SUCCESS) {
|
||||
if (healthState?.status === HealthCheckStatus.SUCCESS)
|
||||
return (
|
||||
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
|
||||
);
|
||||
}
|
||||
|
||||
return server.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any server is still loading (idle or connecting).
|
||||
*/
|
||||
isAnyServerLoading(): boolean {
|
||||
const servers = this.getServers();
|
||||
return servers.some((s) => {
|
||||
return this.getServers().some((s) => {
|
||||
const state = this.getHealthCheckState(s.id);
|
||||
return (
|
||||
state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING
|
||||
|
|
@ -180,29 +274,14 @@ class MCPStore {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get servers sorted alphabetically by display label.
|
||||
* Returns unsorted list while health checks are in progress to prevent UI jumping.
|
||||
*/
|
||||
getServersSorted(): MCPServerSettingsEntry[] {
|
||||
const servers = this.getServers();
|
||||
|
||||
// Don't sort while any server is still loading - prevents UI jumping
|
||||
if (this.isAnyServerLoading()) {
|
||||
return servers;
|
||||
}
|
||||
|
||||
// Sort alphabetically by display label once all health checks are done
|
||||
return [...servers].sort((a, b) => {
|
||||
const labelA = this.getServerLabel(a);
|
||||
const labelB = this.getServerLabel(b);
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
if (this.isAnyServerLoading()) return servers;
|
||||
return [...servers].sort((a, b) =>
|
||||
this.getServerLabel(a).localeCompare(this.getServerLabel(b))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new MCP server
|
||||
*/
|
||||
addServer(
|
||||
serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
|
||||
): void {
|
||||
|
|
@ -218,93 +297,510 @@ class MCPStore {
|
|||
settingsStore.updateConfig('mcpServers', JSON.stringify([...servers, newServer]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing MCP server
|
||||
*/
|
||||
updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
|
||||
const servers = this.getServers();
|
||||
const nextServers = servers.map((server) =>
|
||||
server.id === id ? { ...server, ...updates } : server
|
||||
settingsStore.updateConfig(
|
||||
'mcpServers',
|
||||
JSON.stringify(
|
||||
servers.map((server) => (server.id === id ? { ...server, ...updates } : server))
|
||||
)
|
||||
);
|
||||
settingsStore.updateConfig('mcpServers', JSON.stringify(nextServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an MCP server by ID
|
||||
*/
|
||||
removeServer(id: string): void {
|
||||
const servers = this.getServers();
|
||||
settingsStore.updateConfig('mcpServers', JSON.stringify(servers.filter((s) => s.id !== id)));
|
||||
this.clearHealthCheck(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any available MCP servers (enabled in settings).
|
||||
* Used to determine if McpServerSelector should be shown.
|
||||
*/
|
||||
hasAvailableServers(): boolean {
|
||||
const servers = parseMcpServerSettings(config().mcpServers);
|
||||
return servers.some((s) => s.enabled && s.url.trim());
|
||||
return parseMcpServerSettings(config().mcpServers).some((s) => s.enabled && s.url.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any MCP servers enabled for the current chat.
|
||||
*/
|
||||
hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
|
||||
return Boolean(buildMcpClientConfig(config(), perChatOverrides));
|
||||
return Boolean(buildMcpClientConfigInternal(config(), perChatOverrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets enabled MCP servers for a conversation based on per-chat overrides.
|
||||
* Returns servers that are both globally enabled AND enabled for this chat.
|
||||
*/
|
||||
getEnabledServersForConversation(
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): MCPServerSettingsEntry[] {
|
||||
if (!perChatOverrides?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allServers = this.getServers();
|
||||
|
||||
return allServers.filter((server) => {
|
||||
if (!perChatOverrides?.length) return [];
|
||||
return this.getServers().filter((server) => {
|
||||
if (!server.enabled) return false;
|
||||
const override = perChatOverrides.find((o) => o.serverId === server.id);
|
||||
|
||||
return override?.enabled ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Prompts
|
||||
*
|
||||
*/
|
||||
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
|
||||
if (!browser) return false;
|
||||
const mcpConfig = buildMcpClientConfigInternal(config(), perChatOverrides);
|
||||
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
|
||||
if (!signature) {
|
||||
await this.shutdown();
|
||||
return false;
|
||||
}
|
||||
if (this.isInitialized && this.configSignature === signature) return true;
|
||||
if (this.initPromise && this.configSignature === signature) return this.initPromise;
|
||||
if (this.connections.size > 0 || this.initPromise) await this.shutdown();
|
||||
return this.initialize(signature, mcpConfig!);
|
||||
}
|
||||
|
||||
private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise<boolean> {
|
||||
console.log('[MCPStore] Starting initialization...');
|
||||
this.updateState({ isInitializing: true, error: null });
|
||||
this.configSignature = signature;
|
||||
const serverEntries = Object.entries(mcpConfig.servers);
|
||||
if (serverEntries.length === 0) {
|
||||
console.log('[MCPStore] No servers configured');
|
||||
this.updateState({ isInitializing: false, toolCount: 0, connectedServers: [] });
|
||||
return false;
|
||||
}
|
||||
this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries);
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInitialize(
|
||||
signature: string,
|
||||
mcpConfig: MCPClientConfig,
|
||||
serverEntries: [string, MCPClientConfig['servers'][string]][]
|
||||
): Promise<boolean> {
|
||||
const clientInfo = mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
|
||||
const capabilities = mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
|
||||
const results = await Promise.allSettled(
|
||||
serverEntries.map(async ([name, serverConfig]) => {
|
||||
const listChangedHandlers = this.createListChangedHandlers(name);
|
||||
const connection = await MCPService.connect(
|
||||
name,
|
||||
serverConfig,
|
||||
clientInfo,
|
||||
capabilities,
|
||||
undefined,
|
||||
listChangedHandlers
|
||||
);
|
||||
return { name, connection };
|
||||
})
|
||||
);
|
||||
if (this.configSignature !== signature) {
|
||||
console.log('[MCPStore] Config changed during init, aborting');
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled')
|
||||
await MCPService.disconnect(result.value.connection).catch(console.warn);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const { name, connection } = result.value;
|
||||
this.connections.set(name, connection);
|
||||
for (const tool of connection.tools) {
|
||||
if (this.toolsIndex.has(tool.name))
|
||||
console.warn(
|
||||
`[MCPStore] Tool name conflict: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${name}". Using tool from "${name}".`
|
||||
);
|
||||
this.toolsIndex.set(tool.name, name);
|
||||
}
|
||||
} else {
|
||||
console.error(`[MCPStore] Failed to connect:`, result.reason);
|
||||
}
|
||||
}
|
||||
const successCount = this.connections.size;
|
||||
if (successCount === 0 && serverEntries.length > 0) {
|
||||
this.updateState({
|
||||
isInitializing: false,
|
||||
error: 'All MCP server connections failed',
|
||||
toolCount: 0,
|
||||
connectedServers: []
|
||||
});
|
||||
this.initPromise = null;
|
||||
return false;
|
||||
}
|
||||
this.updateState({
|
||||
isInitializing: false,
|
||||
error: null,
|
||||
toolCount: this.toolsIndex.size,
|
||||
connectedServers: Array.from(this.connections.keys())
|
||||
});
|
||||
console.log(
|
||||
`[MCPStore] Initialization complete: ${successCount}/${serverEntries.length} servers, ${this.toolsIndex.size} tools`
|
||||
);
|
||||
this.initPromise = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private createListChangedHandlers(serverName: string): ListChangedHandlers {
|
||||
return {
|
||||
tools: {
|
||||
onChanged: (error: Error | null, tools: Tool[] | null) => {
|
||||
if (error) {
|
||||
console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error);
|
||||
return;
|
||||
}
|
||||
console.log(`[MCPStore][${serverName}] Tools list changed, ${tools?.length ?? 0} tools`);
|
||||
this.handleToolsListChanged(serverName, tools ?? []);
|
||||
}
|
||||
},
|
||||
prompts: {
|
||||
onChanged: (error: Error | null, prompts: Prompt[] | null) => {
|
||||
if (error) {
|
||||
console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[MCPStore][${serverName}] Prompts list changed, ${prompts?.length ?? 0} prompts`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private handleToolsListChanged(serverName: string, tools: Tool[]): void {
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) return;
|
||||
for (const [toolName, ownerServer] of this.toolsIndex.entries()) {
|
||||
if (ownerServer === serverName) this.toolsIndex.delete(toolName);
|
||||
}
|
||||
connection.tools = tools;
|
||||
for (const tool of tools) {
|
||||
if (this.toolsIndex.has(tool.name))
|
||||
console.warn(
|
||||
`[MCPStore] Tool name conflict after list change: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverName}". Using tool from "${serverName}".`
|
||||
);
|
||||
this.toolsIndex.set(tool.name, serverName);
|
||||
}
|
||||
this.updateState({ toolCount: this.toolsIndex.size });
|
||||
}
|
||||
|
||||
acquireConnection(): void {
|
||||
this.activeFlowCount++;
|
||||
console.log(`[MCPStore] Connection acquired (active flows: ${this.activeFlowCount})`);
|
||||
}
|
||||
|
||||
async releaseConnection(shutdownIfUnused = true): Promise<void> {
|
||||
this.activeFlowCount = Math.max(0, this.activeFlowCount - 1);
|
||||
console.log(`[MCPStore] Connection released (active flows: ${this.activeFlowCount})`);
|
||||
if (shutdownIfUnused && this.activeFlowCount === 0) {
|
||||
console.log('[MCPStore] No active flows, initiating lazy disconnect...');
|
||||
await this.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
getActiveFlowCount(): number {
|
||||
return this.activeFlowCount;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.initPromise) {
|
||||
await this.initPromise.catch(() => {});
|
||||
this.initPromise = null;
|
||||
}
|
||||
if (this.connections.size === 0) return;
|
||||
console.log(`[MCPStore] Shutting down ${this.connections.size} connections...`);
|
||||
await Promise.all(
|
||||
Array.from(this.connections.values()).map((conn) =>
|
||||
MCPService.disconnect(conn).catch((error) =>
|
||||
console.warn(`[MCPStore] Error disconnecting ${conn.serverName}:`, error)
|
||||
)
|
||||
)
|
||||
);
|
||||
this.connections.clear();
|
||||
this.toolsIndex.clear();
|
||||
this.configSignature = null;
|
||||
this.updateState({ isInitializing: false, error: null, toolCount: 0, connectedServers: [] });
|
||||
console.log('[MCPStore] Shutdown complete');
|
||||
}
|
||||
|
||||
getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
|
||||
const tools: OpenAIToolDefinition[] = [];
|
||||
for (const connection of this.connections.values()) {
|
||||
for (const tool of connection.tools) {
|
||||
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
tools.push({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: this.normalizeSchemaProperties(rawSchema)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
private normalizeSchemaProperties(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== 'object') return schema;
|
||||
const normalized = { ...schema };
|
||||
if (normalized.properties && typeof normalized.properties === 'object') {
|
||||
const props = normalized.properties as Record<string, Record<string, unknown>>;
|
||||
const normalizedProps: Record<string, Record<string, unknown>> = {};
|
||||
for (const [key, prop] of Object.entries(props)) {
|
||||
if (!prop || typeof prop !== 'object') {
|
||||
normalizedProps[key] = prop;
|
||||
continue;
|
||||
}
|
||||
const normalizedProp = { ...prop };
|
||||
if (!normalizedProp.type && normalizedProp.default !== undefined) {
|
||||
const defaultVal = normalizedProp.default;
|
||||
if (typeof defaultVal === 'string') normalizedProp.type = 'string';
|
||||
else if (typeof defaultVal === 'number')
|
||||
normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number';
|
||||
else if (typeof defaultVal === 'boolean') normalizedProp.type = 'boolean';
|
||||
else if (Array.isArray(defaultVal)) normalizedProp.type = 'array';
|
||||
else if (typeof defaultVal === 'object' && defaultVal !== null)
|
||||
normalizedProp.type = 'object';
|
||||
}
|
||||
if (normalizedProp.properties)
|
||||
Object.assign(
|
||||
normalizedProp,
|
||||
this.normalizeSchemaProperties(normalizedProp as Record<string, unknown>)
|
||||
);
|
||||
if (normalizedProp.items && typeof normalizedProp.items === 'object')
|
||||
normalizedProp.items = this.normalizeSchemaProperties(
|
||||
normalizedProp.items as Record<string, unknown>
|
||||
);
|
||||
normalizedProps[key] = normalizedProp;
|
||||
}
|
||||
normalized.properties = normalizedProps;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
getToolNames(): string[] {
|
||||
return Array.from(this.toolsIndex.keys());
|
||||
}
|
||||
hasTool(toolName: string): boolean {
|
||||
return this.toolsIndex.has(toolName);
|
||||
}
|
||||
getToolServer(toolName: string): string | undefined {
|
||||
return this.toolsIndex.get(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any connected server supports prompts
|
||||
*/
|
||||
hasPromptsSupport(): boolean {
|
||||
return mcpClient.hasPromptsSupport();
|
||||
for (const connection of this.connections.values()) {
|
||||
if (connection.serverCapabilities?.prompts) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all prompts from all connected servers
|
||||
*/
|
||||
async getAllPrompts(): Promise<MCPPromptInfo[]> {
|
||||
return mcpClient.getAllPrompts();
|
||||
const results: MCPPromptInfo[] = [];
|
||||
for (const [serverName, connection] of this.connections) {
|
||||
if (!connection.serverCapabilities?.prompts) continue;
|
||||
const prompts = await MCPService.listPrompts(connection);
|
||||
for (const prompt of prompts) {
|
||||
results.push({
|
||||
name: prompt.name,
|
||||
description: prompt.description,
|
||||
title: prompt.title,
|
||||
serverName,
|
||||
arguments: prompt.arguments?.map((arg) => ({
|
||||
name: arg.name,
|
||||
description: arg.description,
|
||||
required: arg.required
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific prompt by name from a server.
|
||||
* Throws an error if the server is not found or prompt execution fails.
|
||||
*/
|
||||
async getPrompt(
|
||||
serverName: string,
|
||||
promptName: string,
|
||||
args?: Record<string, string>
|
||||
): Promise<GetPromptResult> {
|
||||
return mcpClient.getPrompt(serverName, promptName, args);
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) throw new Error(`Server "${serverName}" not found for prompt "${promptName}"`);
|
||||
return MCPService.getPrompt(connection, promptName, args);
|
||||
}
|
||||
|
||||
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
||||
const toolName = toolCall.function.name;
|
||||
const serverName = this.toolsIndex.get(toolName);
|
||||
if (!serverName) throw new Error(`Unknown tool: ${toolName}`);
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) throw new Error(`Server "${serverName}" is not connected`);
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
|
||||
}
|
||||
|
||||
async executeToolByName(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolExecutionResult> {
|
||||
const serverName = this.toolsIndex.get(toolName);
|
||||
if (!serverName) throw new Error(`Unknown tool: ${toolName}`);
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) throw new Error(`Server "${serverName}" is not connected`);
|
||||
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
|
||||
}
|
||||
|
||||
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof args === 'string') {
|
||||
const trimmed = args.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
||||
throw new Error(
|
||||
`Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`
|
||||
);
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse tool arguments as JSON: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (typeof args === 'object' && args !== null && !Array.isArray(args)) return args;
|
||||
throw new Error(`Invalid tool arguments type: ${typeof args}`);
|
||||
}
|
||||
|
||||
async getPromptCompletions(
|
||||
serverName: string,
|
||||
promptName: string,
|
||||
argumentName: string,
|
||||
argumentValue: string
|
||||
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) {
|
||||
console.warn(`[MCPStore] Server "${serverName}" is not connected`);
|
||||
return null;
|
||||
}
|
||||
if (!connection.serverCapabilities?.completions) return null;
|
||||
return MCPService.complete(
|
||||
connection,
|
||||
{ type: 'ref/prompt', name: promptName },
|
||||
{ name: argumentName, value: argumentValue }
|
||||
);
|
||||
}
|
||||
|
||||
private parseHeaders(headersJson?: string): Record<string, string> | undefined {
|
||||
if (!headersJson?.trim()) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(headersJson);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
|
||||
return parsed as Record<string, string>;
|
||||
} catch {
|
||||
console.warn('[MCPStore] Failed to parse custom headers JSON:', headersJson);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async runHealthChecksForServers(
|
||||
servers: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
requestTimeoutSeconds: number;
|
||||
headers?: string;
|
||||
}[],
|
||||
skipIfChecked = true
|
||||
): Promise<void> {
|
||||
const serversToCheck = skipIfChecked
|
||||
? servers.filter((s) => !this.hasHealthCheck(s.id) && s.url.trim())
|
||||
: servers.filter((s) => s.url.trim());
|
||||
if (serversToCheck.length === 0) return;
|
||||
const BATCH_SIZE = 5;
|
||||
for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) {
|
||||
const batch = serversToCheck.slice(i, i + BATCH_SIZE);
|
||||
await Promise.all(batch.map((server) => this.runHealthCheck(server)));
|
||||
}
|
||||
}
|
||||
|
||||
async runHealthCheck(server: HealthCheckParams): Promise<void> {
|
||||
const trimmedUrl = server.url.trim();
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE;
|
||||
if (!trimmedUrl) {
|
||||
this.updateHealthCheck(server.id, {
|
||||
status: HealthCheckStatus.ERROR,
|
||||
message: 'Please enter a server URL first.',
|
||||
logs: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.updateHealthCheck(server.id, {
|
||||
status: HealthCheckStatus.CONNECTING,
|
||||
phase: MCPConnectionPhase.TRANSPORT_CREATING,
|
||||
logs: []
|
||||
});
|
||||
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
|
||||
const headers = this.parseHeaders(server.headers);
|
||||
try {
|
||||
const connection = await MCPService.connect(
|
||||
server.id,
|
||||
{
|
||||
url: trimmedUrl,
|
||||
transport: detectMcpTransportFromUrl(trimmedUrl),
|
||||
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
|
||||
requestTimeoutMs: timeoutMs,
|
||||
headers
|
||||
},
|
||||
DEFAULT_MCP_CONFIG.clientInfo,
|
||||
DEFAULT_MCP_CONFIG.capabilities,
|
||||
(phase, log) => {
|
||||
currentPhase = phase;
|
||||
logs.push(log);
|
||||
this.updateHealthCheck(server.id, {
|
||||
status: HealthCheckStatus.CONNECTING,
|
||||
phase,
|
||||
logs: [...logs]
|
||||
});
|
||||
}
|
||||
);
|
||||
const tools = connection.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
title: tool.title
|
||||
}));
|
||||
const capabilities = buildCapabilitiesInfo(
|
||||
connection.serverCapabilities,
|
||||
connection.clientCapabilities
|
||||
);
|
||||
this.updateHealthCheck(server.id, {
|
||||
status: HealthCheckStatus.SUCCESS,
|
||||
tools,
|
||||
serverInfo: connection.serverInfo,
|
||||
capabilities,
|
||||
transportType: connection.transportType,
|
||||
protocolVersion: connection.protocolVersion,
|
||||
instructions: connection.instructions,
|
||||
connectionTimeMs: connection.connectionTimeMs,
|
||||
logs
|
||||
});
|
||||
await MCPService.disconnect(connection);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
logs.push({
|
||||
timestamp: new Date(),
|
||||
phase: MCPConnectionPhase.ERROR,
|
||||
message: `Connection failed: ${message}`,
|
||||
level: MCPLogLevel.ERROR
|
||||
});
|
||||
this.updateHealthCheck(server.id, {
|
||||
status: HealthCheckStatus.ERROR,
|
||||
message,
|
||||
phase: currentPhase,
|
||||
logs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getServersStatus(): ServerStatus[] {
|
||||
const statuses: ServerStatus[] = [];
|
||||
for (const [name, connection] of this.connections) {
|
||||
statuses.push({
|
||||
name,
|
||||
isConnected: true,
|
||||
toolCount: connection.tools.length,
|
||||
error: undefined
|
||||
});
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue