refactor: Cleanup
This commit is contained in:
parent
74b119e81e
commit
144148125b
|
|
@ -0,0 +1,622 @@
|
|||
/**
|
||||
* AgenticClient - Business Logic Facade for Agentic Loop Orchestration
|
||||
*
|
||||
* Coordinates the multi-turn agentic loop with MCP tools:
|
||||
* - LLM streaming with tool call detection
|
||||
* - Tool execution via MCPClient
|
||||
* - Session state management
|
||||
* - Turn limit enforcement
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **AgenticClient** (this class): Orchestrates multi-turn tool loop
|
||||
* - Uses MCPClient for tool execution
|
||||
* - Uses ChatService for LLM streaming
|
||||
* - Updates agenticStore with reactive state
|
||||
*
|
||||
* - **MCPClient**: Tool execution facade
|
||||
* - **agenticStore**: Reactive state only ($state)
|
||||
*
|
||||
* **Key Features:**
|
||||
* - Multi-turn tool call orchestration
|
||||
* - Automatic routing of tool calls to appropriate MCP servers
|
||||
* - Raw LLM output streaming (UI formatting is separate concern)
|
||||
* - Lazy disconnect after flow completes
|
||||
*/
|
||||
|
||||
import { mcpClient } from '$lib/clients';
|
||||
import { ChatService } from '$lib/services';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { getAgenticConfig } from '$lib/utils/agentic';
|
||||
import { toAgenticMessages } from '$lib/utils';
|
||||
import type { AgenticMessage, AgenticToolCallList } from '$lib/types/agentic';
|
||||
import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api';
|
||||
import type {
|
||||
ChatMessagePromptProgress,
|
||||
ChatMessageTimings,
|
||||
ChatMessageAgenticTimings,
|
||||
ChatMessageToolCallTiming,
|
||||
ChatMessageAgenticTurnStats
|
||||
} from '$lib/types/chat';
|
||||
import type { MCPToolCall } from '$lib/types/mcp';
|
||||
import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '$lib/types/database';
|
||||
|
||||
export interface AgenticFlowCallbacks {
|
||||
onChunk?: (chunk: string) => void;
|
||||
onReasoningChunk?: (chunk: string) => void;
|
||||
onToolCallChunk?: (serializedToolCalls: string) => void;
|
||||
onModel?: (model: string) => void;
|
||||
onComplete?: (
|
||||
content: string,
|
||||
reasoningContent?: string,
|
||||
timings?: ChatMessageTimings,
|
||||
toolCalls?: string
|
||||
) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
|
||||
}
|
||||
|
||||
export interface AgenticFlowOptions {
|
||||
stream?: boolean;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgenticFlowParams {
|
||||
messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[];
|
||||
options?: AgenticFlowOptions;
|
||||
callbacks: AgenticFlowCallbacks;
|
||||
signal?: AbortSignal;
|
||||
/** Per-chat MCP server overrides */
|
||||
perChatOverrides?: McpServerOverride[];
|
||||
}
|
||||
|
||||
export interface AgenticFlowResult {
|
||||
handled: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface AgenticStoreStateCallbacks {
|
||||
setRunning: (running: boolean) => void;
|
||||
setCurrentTurn: (turn: number) => void;
|
||||
setTotalToolCalls: (count: number) => void;
|
||||
setLastError: (error: Error | null) => void;
|
||||
setStreamingToolCall: (tc: { name: string; arguments: string } | null) => void;
|
||||
clearStreamingToolCall: () => void;
|
||||
}
|
||||
|
||||
export class AgenticClient {
|
||||
private storeCallbacks: AgenticStoreStateCallbacks | null = null;
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Store Integration
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sets callbacks for store state updates.
|
||||
* Called by agenticStore during initialization.
|
||||
*/
|
||||
setStoreCallbacks(callbacks: AgenticStoreStateCallbacks): void {
|
||||
this.storeCallbacks = callbacks;
|
||||
}
|
||||
|
||||
private get store(): AgenticStoreStateCallbacks {
|
||||
if (!this.storeCallbacks) {
|
||||
throw new Error('AgenticClient: Store callbacks not initialized');
|
||||
}
|
||||
return this.storeCallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Agentic Flow
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runs the agentic orchestration loop with MCP tools.
|
||||
* Main entry point called by ChatClient when agentic mode is enabled.
|
||||
*
|
||||
* Coordinates: initial LLM request → tool call detection → tool execution → loop until done.
|
||||
*
|
||||
* @param params - Flow parameters including messages, options, callbacks, and signal
|
||||
* @returns Result indicating if the flow handled the request
|
||||
*/
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
const { messages, options = {}, callbacks, signal, perChatOverrides } = params;
|
||||
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } =
|
||||
callbacks;
|
||||
|
||||
// Get agentic configuration (considering per-chat MCP overrides)
|
||||
const agenticConfig = getAgenticConfig(config(), perChatOverrides);
|
||||
if (!agenticConfig.enabled) {
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
// Ensure MCP is initialized with per-chat overrides
|
||||
const initialized = await mcpClient.ensureInitialized(perChatOverrides);
|
||||
if (!initialized) {
|
||||
console.log('[AgenticClient] MCP not initialized, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
const tools = mcpClient.getToolDefinitionsForLLM();
|
||||
if (tools.length === 0) {
|
||||
console.log('[AgenticClient] No tools available, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
console.log(`[AgenticClient] Starting agentic flow with ${tools.length} tools`);
|
||||
|
||||
const normalizedMessages: ApiChatMessageData[] = messages
|
||||
.map((msg) => {
|
||||
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
|
||||
// DatabaseMessage - use ChatService to convert
|
||||
return ChatService.convertDbMessageToApiChatMessageData(
|
||||
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
||||
);
|
||||
}
|
||||
return msg as ApiChatMessageData;
|
||||
})
|
||||
.filter((msg) => {
|
||||
if (msg.role === 'system') {
|
||||
const content = typeof msg.content === 'string' ? msg.content : '';
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
this.store.setRunning(true);
|
||||
this.store.setCurrentTurn(0);
|
||||
this.store.setTotalToolCalls(0);
|
||||
this.store.setLastError(null);
|
||||
|
||||
try {
|
||||
await this.executeAgenticLoop({
|
||||
messages: normalizedMessages,
|
||||
options,
|
||||
tools,
|
||||
agenticConfig,
|
||||
callbacks: {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onModel,
|
||||
onComplete,
|
||||
onError,
|
||||
onTimings
|
||||
},
|
||||
signal
|
||||
});
|
||||
return { handled: true };
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
this.store.setLastError(normalizedError);
|
||||
onError?.(normalizedError);
|
||||
return { handled: true, error: normalizedError };
|
||||
} finally {
|
||||
this.store.setRunning(false);
|
||||
// Lazy Disconnect: Close MCP connections after agentic flow completes
|
||||
// This prevents continuous keepalive/heartbeat polling when tools are not in use
|
||||
await mcpClient.shutdown().catch((err) => {
|
||||
console.warn('[AgenticClient] Failed to shutdown MCP after flow:', err);
|
||||
});
|
||||
|
||||
console.log('[AgenticClient] MCP connections closed (lazy disconnect)');
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAgenticLoop(params: {
|
||||
messages: ApiChatMessageData[];
|
||||
options: AgenticFlowOptions;
|
||||
tools: ReturnType<typeof mcpClient.getToolDefinitionsForLLM>;
|
||||
agenticConfig: ReturnType<typeof getAgenticConfig>;
|
||||
callbacks: AgenticFlowCallbacks;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { messages, options, tools, agenticConfig, callbacks, signal } = params;
|
||||
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onTimings } =
|
||||
callbacks;
|
||||
|
||||
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
|
||||
const allToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let capturedTimings: ChatMessageTimings | undefined;
|
||||
|
||||
const agenticTimings: ChatMessageAgenticTimings = {
|
||||
turns: 0,
|
||||
toolCallsCount: 0,
|
||||
toolsMs: 0,
|
||||
toolCalls: [],
|
||||
perTurn: [],
|
||||
llm: {
|
||||
predicted_n: 0,
|
||||
predicted_ms: 0,
|
||||
prompt_n: 0,
|
||||
prompt_ms: 0
|
||||
}
|
||||
};
|
||||
|
||||
const maxTurns = agenticConfig.maxTurns;
|
||||
const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
|
||||
|
||||
for (let turn = 0; turn < maxTurns; turn++) {
|
||||
this.store.setCurrentTurn(turn + 1);
|
||||
agenticTimings.turns = turn + 1;
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter reasoning content after first turn if configured
|
||||
const shouldFilterReasoning = agenticConfig.filterReasoningAfterFirstTurn && turn > 0;
|
||||
|
||||
let turnContent = '';
|
||||
let turnToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let lastStreamingToolCallName = '';
|
||||
let lastStreamingToolCallArgsLength = 0;
|
||||
|
||||
let turnTimings: ChatMessageTimings | undefined;
|
||||
|
||||
const turnStats: ChatMessageAgenticTurnStats = {
|
||||
turn: turn + 1,
|
||||
llm: {
|
||||
predicted_n: 0,
|
||||
predicted_ms: 0,
|
||||
prompt_n: 0,
|
||||
prompt_ms: 0
|
||||
},
|
||||
toolCalls: [],
|
||||
toolsMs: 0
|
||||
};
|
||||
|
||||
try {
|
||||
await ChatService.sendMessage(
|
||||
sessionMessages as ApiChatMessageData[],
|
||||
{
|
||||
...options,
|
||||
stream: true,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
onChunk: (chunk: string) => {
|
||||
turnContent += chunk;
|
||||
onChunk?.(chunk);
|
||||
},
|
||||
onReasoningChunk: shouldFilterReasoning ? undefined : onReasoningChunk,
|
||||
onToolCallChunk: (serialized: string) => {
|
||||
try {
|
||||
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
|
||||
// Update store with streaming tool call state for UI visualization
|
||||
// Only update when values actually change to avoid memory pressure
|
||||
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
|
||||
const name = turnToolCalls[0].function.name || '';
|
||||
const args = turnToolCalls[0].function.arguments || '';
|
||||
// Only update if name changed or args grew significantly (every 100 chars)
|
||||
const argsLengthBucket = Math.floor(args.length / 100);
|
||||
if (
|
||||
name !== lastStreamingToolCallName ||
|
||||
argsLengthBucket !== lastStreamingToolCallArgsLength
|
||||
) {
|
||||
lastStreamingToolCallName = name;
|
||||
lastStreamingToolCallArgsLength = argsLengthBucket;
|
||||
this.store.setStreamingToolCall({ name, arguments: args });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors during streaming
|
||||
}
|
||||
},
|
||||
onModel,
|
||||
onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => {
|
||||
onTimings?.(timings, progress);
|
||||
if (timings) {
|
||||
capturedTimings = timings;
|
||||
turnTimings = timings;
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
// Completion handled after sendMessage resolves
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
signal
|
||||
);
|
||||
|
||||
this.store.clearStreamingToolCall();
|
||||
|
||||
if (turnTimings) {
|
||||
agenticTimings.llm.predicted_n += turnTimings.predicted_n || 0;
|
||||
agenticTimings.llm.predicted_ms += turnTimings.predicted_ms || 0;
|
||||
agenticTimings.llm.prompt_n += turnTimings.prompt_n || 0;
|
||||
agenticTimings.llm.prompt_ms += turnTimings.prompt_ms || 0;
|
||||
|
||||
turnStats.llm.predicted_n = turnTimings.predicted_n || 0;
|
||||
turnStats.llm.predicted_ms = turnTimings.predicted_ms || 0;
|
||||
turnStats.llm.prompt_n = turnTimings.prompt_n || 0;
|
||||
turnStats.llm.prompt_ms = turnTimings.prompt_ms || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
|
||||
onChunk?.(`\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`);
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
throw normalizedError;
|
||||
}
|
||||
|
||||
if (turnToolCalls.length === 0) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
|
||||
if (normalizedCalls.length === 0) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const call of normalizedCalls) {
|
||||
allToolCalls.push({
|
||||
id: call.id,
|
||||
type: call.type,
|
||||
function: call.function ? { ...call.function } : undefined
|
||||
});
|
||||
}
|
||||
this.store.setTotalToolCalls(allToolCalls.length);
|
||||
onToolCallChunk?.(JSON.stringify(allToolCalls));
|
||||
|
||||
sessionMessages.push({
|
||||
role: 'assistant',
|
||||
content: turnContent || undefined,
|
||||
tool_calls: normalizedCalls
|
||||
});
|
||||
|
||||
for (const toolCall of normalizedCalls) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start timing BEFORE emitToolCallStart to capture full perceived execution time
|
||||
const toolStartTime = performance.now();
|
||||
this.emitToolCallStart(toolCall, onChunk);
|
||||
|
||||
const mcpCall: MCPToolCall = {
|
||||
id: toolCall.id,
|
||||
function: {
|
||||
name: toolCall.function.name,
|
||||
arguments: toolCall.function.arguments
|
||||
}
|
||||
};
|
||||
|
||||
let result: string;
|
||||
let toolSuccess = true;
|
||||
|
||||
try {
|
||||
const executionResult = await mcpClient.executeTool(mcpCall, signal);
|
||||
result = executionResult.content;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
toolSuccess = false;
|
||||
}
|
||||
|
||||
const toolDurationMs = performance.now() - toolStartTime;
|
||||
|
||||
const toolTiming: ChatMessageToolCallTiming = {
|
||||
name: toolCall.function.name,
|
||||
duration_ms: Math.round(toolDurationMs),
|
||||
success: toolSuccess
|
||||
};
|
||||
|
||||
agenticTimings.toolCalls!.push(toolTiming);
|
||||
agenticTimings.toolCallsCount++;
|
||||
agenticTimings.toolsMs += Math.round(toolDurationMs);
|
||||
|
||||
turnStats.toolCalls.push(toolTiming);
|
||||
turnStats.toolsMs += Math.round(toolDurationMs);
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitToolCallResult(result, maxToolPreviewLines, onChunk);
|
||||
|
||||
// Add tool result to session (sanitize base64 images for context)
|
||||
const contextValue = this.isBase64Image(result) ? '[Image displayed to user]' : result;
|
||||
sessionMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: contextValue
|
||||
});
|
||||
}
|
||||
|
||||
// Save per-turn stats (only if there were tool calls in this turn)
|
||||
if (turnStats.toolCalls.length > 0) {
|
||||
agenticTimings.perTurn!.push(turnStats);
|
||||
}
|
||||
}
|
||||
|
||||
onChunk?.('\n\n```\nTurn limit reached\n```\n');
|
||||
onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Timing & Statistics
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds final timings object with agentic stats.
|
||||
* Single-turn flows return original timings; multi-turn includes aggregated stats.
|
||||
*/
|
||||
private buildFinalTimings(
|
||||
capturedTimings: ChatMessageTimings | undefined,
|
||||
agenticTimings: ChatMessageAgenticTimings
|
||||
): ChatMessageTimings | undefined {
|
||||
// If no tool calls were made, this was effectively a single-turn flow
|
||||
// Return the original timings without agentic data
|
||||
if (agenticTimings.toolCallsCount === 0) {
|
||||
return capturedTimings;
|
||||
}
|
||||
|
||||
const finalTimings: ChatMessageTimings = {
|
||||
// Use the last turn's values as the "current" values for backward compatibility
|
||||
predicted_n: capturedTimings?.predicted_n,
|
||||
predicted_ms: capturedTimings?.predicted_ms,
|
||||
prompt_n: capturedTimings?.prompt_n,
|
||||
prompt_ms: capturedTimings?.prompt_ms,
|
||||
cache_n: capturedTimings?.cache_n,
|
||||
agentic: agenticTimings
|
||||
};
|
||||
|
||||
return finalTimings;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Tool Call Processing
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
|
||||
if (!toolCalls) return [];
|
||||
return toolCalls.map((call, index) => ({
|
||||
id: call?.id ?? `tool_${index}`,
|
||||
type: (call?.type as 'function') ?? 'function',
|
||||
function: {
|
||||
name: call?.function?.name ?? '',
|
||||
arguments: call?.function?.arguments ?? ''
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit tool call start marker (shows "pending" state in UI).
|
||||
*/
|
||||
private emitToolCallStart(
|
||||
toolCall: AgenticToolCallList[number],
|
||||
emit?: (chunk: string) => void
|
||||
): void {
|
||||
if (!emit) return;
|
||||
|
||||
const toolName = toolCall.function.name;
|
||||
const toolArgs = toolCall.function.arguments;
|
||||
// Base64 encode args to avoid conflicts with markdown/HTML parsing
|
||||
const toolArgsBase64 = btoa(unescape(encodeURIComponent(toolArgs)));
|
||||
|
||||
let output = `\n\n<<<AGENTIC_TOOL_CALL_START>>>`;
|
||||
output += `\n<<<TOOL_NAME:${toolName}>>>`;
|
||||
output += `\n<<<TOOL_ARGS_BASE64:${toolArgsBase64}>>>`;
|
||||
emit(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit tool call result and end marker.
|
||||
*/
|
||||
private emitToolCallResult(
|
||||
result: string,
|
||||
maxLines: number,
|
||||
emit?: (chunk: string) => void
|
||||
): void {
|
||||
if (!emit) return;
|
||||
|
||||
let output = '';
|
||||
if (this.isBase64Image(result)) {
|
||||
output += `\n})`;
|
||||
} else {
|
||||
// Don't wrap in code fences - result may already be markdown with its own code blocks
|
||||
const lines = result.split('\n');
|
||||
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
|
||||
output += `\n${trimmedLines.join('\n')}`;
|
||||
}
|
||||
|
||||
output += `\n<<<AGENTIC_TOOL_CALL_END>>>\n`;
|
||||
emit(output);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Utilities
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
private isBase64Image(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith('data:image/')) return false;
|
||||
|
||||
const match = trimmed.match(/^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+=*)$/);
|
||||
if (!match) return false;
|
||||
|
||||
const base64Payload = match[2];
|
||||
return base64Payload.length > 0 && base64Payload.length % 4 === 0;
|
||||
}
|
||||
|
||||
clearError(): void {
|
||||
this.store.setLastError(null);
|
||||
}
|
||||
}
|
||||
|
||||
export const agenticClient = new AgenticClient();
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,713 @@
|
|||
/**
|
||||
* ConversationsClient - Business Logic Facade for Conversation Operations
|
||||
*
|
||||
* Coordinates conversation lifecycle, persistence, and navigation.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ConversationsClient** (this class): Business logic facade
|
||||
* - Uses DatabaseService for IndexedDB operations
|
||||
* - Updates conversationsStore with reactive state
|
||||
* - Handles CRUD, import/export, branch navigation
|
||||
*
|
||||
* - **DatabaseService**: Stateless IndexedDB layer
|
||||
* - **conversationsStore**: Reactive state only ($state)
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Conversation lifecycle (create, load, delete)
|
||||
* - Message management and tree navigation
|
||||
* - MCP server per-chat overrides
|
||||
* - Import/Export functionality
|
||||
* - Title management with confirmation
|
||||
*/
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
|
||||
interface ConversationsStoreStateCallbacks {
|
||||
getConversations: () => DatabaseConversation[];
|
||||
setConversations: (conversations: DatabaseConversation[]) => void;
|
||||
getActiveConversation: () => DatabaseConversation | null;
|
||||
setActiveConversation: (conversation: DatabaseConversation | null) => void;
|
||||
getActiveMessages: () => DatabaseMessage[];
|
||||
setActiveMessages: (messages: DatabaseMessage[]) => void;
|
||||
updateActiveMessages: (updater: (messages: DatabaseMessage[]) => DatabaseMessage[]) => void;
|
||||
setInitialized: (initialized: boolean) => void;
|
||||
getPendingMcpServerOverrides: () => McpServerOverride[];
|
||||
setPendingMcpServerOverrides: (overrides: McpServerOverride[]) => void;
|
||||
getTitleUpdateConfirmationCallback: () =>
|
||||
| ((currentTitle: string, newTitle: string) => Promise<boolean>)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export class ConversationsClient {
|
||||
private storeCallbacks: ConversationsStoreStateCallbacks | null = null;
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Store Integration
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sets callbacks for store state updates.
|
||||
* Called by conversationsStore during initialization.
|
||||
*/
|
||||
setStoreCallbacks(callbacks: ConversationsStoreStateCallbacks): void {
|
||||
this.storeCallbacks = callbacks;
|
||||
}
|
||||
|
||||
private get store(): ConversationsStoreStateCallbacks {
|
||||
if (!this.storeCallbacks) {
|
||||
throw new Error('ConversationsClient: Store callbacks not initialized');
|
||||
}
|
||||
return this.storeCallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Lifecycle
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initializes the conversations by loading from the database.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.loadConversations();
|
||||
this.store.setInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize conversations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all conversations from the database
|
||||
*/
|
||||
async loadConversations(): Promise<void> {
|
||||
const conversations = await DatabaseService.getAllConversations();
|
||||
this.store.setConversations(conversations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new conversation and navigates to it
|
||||
* @param name - Optional name for the conversation
|
||||
* @returns The ID of the created conversation
|
||||
*/
|
||||
async createConversation(name?: string): Promise<string> {
|
||||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||
const conversation = await DatabaseService.createConversation(conversationName);
|
||||
|
||||
const pendingOverrides = this.store.getPendingMcpServerOverrides();
|
||||
if (pendingOverrides.length > 0) {
|
||||
// Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
|
||||
const plainOverrides = pendingOverrides.map((o) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}));
|
||||
conversation.mcpServerOverrides = plainOverrides;
|
||||
await DatabaseService.updateConversation(conversation.id, {
|
||||
mcpServerOverrides: plainOverrides
|
||||
});
|
||||
this.store.setPendingMcpServerOverrides([]);
|
||||
}
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
this.store.setConversations([conversation, ...conversations]);
|
||||
this.store.setActiveConversation(conversation);
|
||||
this.store.setActiveMessages([]);
|
||||
|
||||
await goto(`#/chat/${conversation.id}`);
|
||||
|
||||
return conversation.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a specific conversation and its messages
|
||||
* @param convId - The conversation ID to load
|
||||
* @returns True if conversation was loaded successfully
|
||||
*/
|
||||
async loadConversation(convId: string): Promise<boolean> {
|
||||
try {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
|
||||
if (!conversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.store.setPendingMcpServerOverrides([]);
|
||||
this.store.setActiveConversation(conversation);
|
||||
|
||||
if (conversation.currNode) {
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
const filteredMessages = filterByLeafNodeId(
|
||||
allMessages,
|
||||
conversation.currNode,
|
||||
false
|
||||
) as DatabaseMessage[];
|
||||
this.store.setActiveMessages(filteredMessages);
|
||||
} else {
|
||||
const messages = await DatabaseService.getConversationMessages(convId);
|
||||
this.store.setActiveMessages(messages);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Conversation CRUD
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clears the active conversation and messages.
|
||||
*/
|
||||
clearActiveConversation(): void {
|
||||
this.store.setActiveConversation(null);
|
||||
this.store.setActiveMessages([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a conversation and all its messages
|
||||
* @param convId - The conversation ID to delete
|
||||
*/
|
||||
async deleteConversation(convId: string): Promise<void> {
|
||||
try {
|
||||
await DatabaseService.deleteConversation(convId);
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
this.store.setConversations(conversations.filter((c) => c.id !== convId));
|
||||
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (activeConv?.id === convId) {
|
||||
this.clearActiveConversation();
|
||||
await goto(`?new_chat=true#/`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all conversations and their messages
|
||||
*/
|
||||
async deleteAll(): Promise<void> {
|
||||
try {
|
||||
const allConversations = await DatabaseService.getAllConversations();
|
||||
|
||||
for (const conv of allConversations) {
|
||||
await DatabaseService.deleteConversation(conv.id);
|
||||
}
|
||||
|
||||
this.clearActiveConversation();
|
||||
this.store.setConversations([]);
|
||||
|
||||
toast.success('All conversations deleted');
|
||||
|
||||
await goto(`?new_chat=true#/`);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete all conversations:', error);
|
||||
toast.error('Failed to delete conversations');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Message Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Refreshes active messages based on currNode after branch navigation.
|
||||
*/
|
||||
async refreshActiveMessages(): Promise<void> {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (!activeConv) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(activeConv.id);
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
this.store.setActiveMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const leafNodeId =
|
||||
activeConv.currNode ||
|
||||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
|
||||
|
||||
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
||||
|
||||
this.store.setActiveMessages(currentPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all messages for a specific conversation
|
||||
* @param convId - The conversation ID
|
||||
* @returns Array of messages
|
||||
*/
|
||||
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||
return await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Title Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Updates the name of a conversation.
|
||||
* @param convId - The conversation ID to update
|
||||
* @param name - The new name for the conversation
|
||||
*/
|
||||
async updateConversationName(convId: string, name: string): Promise<void> {
|
||||
try {
|
||||
await DatabaseService.updateConversation(convId, { name });
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
const convIndex = conversations.findIndex((c) => c.id === convId);
|
||||
|
||||
if (convIndex !== -1) {
|
||||
conversations[convIndex].name = name;
|
||||
this.store.setConversations([...conversations]);
|
||||
}
|
||||
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (activeConv?.id === convId) {
|
||||
this.store.setActiveConversation({ ...activeConv, name });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation name:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation title with optional confirmation dialog based on settings
|
||||
* @param convId - The conversation ID to update
|
||||
* @param newTitle - The new title content
|
||||
* @returns True if title was updated, false if cancelled
|
||||
*/
|
||||
async updateConversationTitleWithConfirmation(
|
||||
convId: string,
|
||||
newTitle: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const currentConfig = config();
|
||||
const onConfirmationNeeded = this.store.getTitleUpdateConfirmationCallback();
|
||||
|
||||
if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return false;
|
||||
|
||||
const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
|
||||
if (!shouldUpdate) return false;
|
||||
}
|
||||
|
||||
await this.updateConversationName(convId, newTitle);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation title with confirmation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation lastModified timestamp and moves it to top of list
|
||||
*/
|
||||
updateConversationTimestamp(): void {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (!activeConv) return;
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
const chatIndex = conversations.findIndex((c) => c.id === activeConv.id);
|
||||
|
||||
if (chatIndex !== -1) {
|
||||
conversations[chatIndex].lastModified = Date.now();
|
||||
const updatedConv = conversations.splice(chatIndex, 1)[0];
|
||||
this.store.setConversations([updatedConv, ...conversations]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current node of the active conversation
|
||||
* @param nodeId - The new current node ID
|
||||
*/
|
||||
async updateCurrentNode(nodeId: string): Promise<void> {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (!activeConv) return;
|
||||
|
||||
await DatabaseService.updateCurrentNode(activeConv.id, nodeId);
|
||||
this.store.setActiveConversation({ ...activeConv, currNode: nodeId });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Branch Navigation
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Navigates to a specific sibling branch by updating currNode and refreshing messages.
|
||||
* @param siblingId - The sibling message ID to navigate to
|
||||
*/
|
||||
async navigateToSibling(siblingId: string): Promise<void> {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (!activeConv) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(activeConv.id);
|
||||
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
||||
const activeMessages = this.store.getActiveMessages();
|
||||
const currentFirstUserMessage = activeMessages.find(
|
||||
(m) => m.role === 'user' && m.parent === rootMessage?.id
|
||||
);
|
||||
|
||||
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
||||
|
||||
await DatabaseService.updateCurrentNode(activeConv.id, currentLeafNodeId);
|
||||
this.store.setActiveConversation({ ...activeConv, currNode: currentLeafNodeId });
|
||||
await this.refreshActiveMessages();
|
||||
|
||||
const updatedActiveMessages = this.store.getActiveMessages();
|
||||
if (rootMessage && updatedActiveMessages.length > 0) {
|
||||
const newFirstUserMessage = updatedActiveMessages.find(
|
||||
(m) => m.role === 'user' && m.parent === rootMessage.id
|
||||
);
|
||||
|
||||
if (
|
||||
newFirstUserMessage &&
|
||||
newFirstUserMessage.content.trim() &&
|
||||
(!currentFirstUserMessage ||
|
||||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
|
||||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
|
||||
) {
|
||||
await this.updateConversationTitleWithConfirmation(
|
||||
activeConv.id,
|
||||
newFirstUserMessage.content.trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* MCP Server Overrides
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets MCP server override for a specific server in the active conversation.
|
||||
* Falls back to pending overrides if no active conversation exists.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns The override if set, undefined if using global setting
|
||||
*/
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (activeConv) {
|
||||
return activeConv.mcpServerOverrides?.find((o: McpServerOverride) => o.serverId === serverId);
|
||||
}
|
||||
return this.store.getPendingMcpServerOverrides().find((o) => o.serverId === serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an MCP server is enabled for the active conversation.
|
||||
* Per-chat override takes precedence over global setting.
|
||||
* @param serverId - The server ID to check
|
||||
* @param globalEnabled - The global enabled state from settings
|
||||
* @returns True if server is enabled for this conversation
|
||||
*/
|
||||
isMcpServerEnabledForChat(serverId: string, globalEnabled: boolean): boolean {
|
||||
const override = this.getMcpServerOverride(serverId);
|
||||
return override !== undefined ? override.enabled : globalEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes MCP server override for the active conversation.
|
||||
* If no conversation exists, stores as pending override.
|
||||
* @param serverId - The server ID to override
|
||||
* @param enabled - The enabled state, or undefined to remove override
|
||||
*/
|
||||
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
|
||||
if (!activeConv) {
|
||||
this.setPendingMcpServerOverride(serverId, enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
|
||||
const currentOverrides = (activeConv.mcpServerOverrides || []).map((o: McpServerOverride) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}));
|
||||
let newOverrides: McpServerOverride[];
|
||||
|
||||
if (enabled === undefined) {
|
||||
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
|
||||
} else {
|
||||
const existingIndex = currentOverrides.findIndex(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
newOverrides = [...currentOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
} else {
|
||||
newOverrides = [...currentOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
|
||||
await DatabaseService.updateConversation(activeConv.id, {
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
});
|
||||
|
||||
const updatedConv = {
|
||||
...activeConv,
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
};
|
||||
this.store.setActiveConversation(updatedConv);
|
||||
|
||||
const conversations = this.store.getConversations();
|
||||
const convIndex = conversations.findIndex((c) => c.id === activeConv.id);
|
||||
if (convIndex !== -1) {
|
||||
conversations[convIndex].mcpServerOverrides =
|
||||
newOverrides.length > 0 ? newOverrides : undefined;
|
||||
this.store.setConversations([...conversations]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles MCP server enabled state for the active conversation.
|
||||
* @param serverId - The server ID to toggle
|
||||
* @param globalEnabled - The global enabled state from settings
|
||||
*/
|
||||
async toggleMcpServerForChat(serverId: string, globalEnabled: boolean): Promise<void> {
|
||||
const currentEnabled = this.isMcpServerEnabledForChat(serverId, globalEnabled);
|
||||
await this.setMcpServerOverride(serverId, !currentEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets MCP server to use global setting (removes per-chat override).
|
||||
* @param serverId - The server ID to reset
|
||||
*/
|
||||
async resetMcpServerToGlobal(serverId: string): Promise<void> {
|
||||
await this.setMcpServerOverride(serverId, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes a pending MCP server override (for new conversations).
|
||||
*/
|
||||
private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
|
||||
const pendingOverrides = this.store.getPendingMcpServerOverrides();
|
||||
|
||||
if (enabled === undefined) {
|
||||
this.store.setPendingMcpServerOverrides(
|
||||
pendingOverrides.filter((o) => o.serverId !== serverId)
|
||||
);
|
||||
} else {
|
||||
const existingIndex = pendingOverrides.findIndex((o) => o.serverId === serverId);
|
||||
if (existingIndex >= 0) {
|
||||
const newOverrides = [...pendingOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
this.store.setPendingMcpServerOverrides(newOverrides);
|
||||
} else {
|
||||
this.store.setPendingMcpServerOverrides([...pendingOverrides, { serverId, enabled }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all pending MCP server overrides.
|
||||
*/
|
||||
clearPendingMcpServerOverrides(): void {
|
||||
this.store.setPendingMcpServerOverrides([]);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import & Export
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Downloads a conversation as JSON file.
|
||||
* @param convId - The conversation ID to download
|
||||
*/
|
||||
async downloadConversation(convId: string): Promise<void> {
|
||||
let conversation: DatabaseConversation | null;
|
||||
let messages: DatabaseMessage[];
|
||||
|
||||
const activeConv = this.store.getActiveConversation();
|
||||
if (activeConv?.id === convId) {
|
||||
conversation = activeConv;
|
||||
messages = this.store.getActiveMessages();
|
||||
} else {
|
||||
conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return;
|
||||
messages = await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
this.triggerDownload({ conv: conversation, messages });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all conversations with their messages as a JSON file
|
||||
* @returns The list of exported conversations
|
||||
*/
|
||||
async exportAllConversations(): Promise<DatabaseConversation[]> {
|
||||
const allConversations = await DatabaseService.getAllConversations();
|
||||
|
||||
if (allConversations.length === 0) {
|
||||
throw new Error('No conversations to export');
|
||||
}
|
||||
|
||||
const allData = await Promise.all(
|
||||
allConversations.map(async (conv) => {
|
||||
const messages = await DatabaseService.getConversationMessages(conv.id);
|
||||
return { conv, messages };
|
||||
})
|
||||
);
|
||||
|
||||
const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`All conversations (${allConversations.length}) prepared for download`);
|
||||
|
||||
return allConversations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports conversations from a JSON file
|
||||
* Opens file picker and processes the selected file
|
||||
* @returns The list of imported conversations
|
||||
*/
|
||||
async importConversations(): Promise<DatabaseConversation[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement)?.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
reject(new Error('No file selected'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsedData = JSON.parse(text);
|
||||
let importedData: ExportedConversations;
|
||||
|
||||
if (Array.isArray(parsedData)) {
|
||||
importedData = parsedData;
|
||||
} else if (
|
||||
parsedData &&
|
||||
typeof parsedData === 'object' &&
|
||||
'conv' in parsedData &&
|
||||
'messages' in parsedData
|
||||
) {
|
||||
importedData = [parsedData];
|
||||
} else {
|
||||
throw new Error('Invalid file format');
|
||||
}
|
||||
|
||||
const result = await DatabaseService.importConversations(importedData);
|
||||
toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
|
||||
|
||||
await this.loadConversations();
|
||||
|
||||
const importedConversations = (
|
||||
Array.isArray(importedData) ? importedData : [importedData]
|
||||
).map((item) => item.conv);
|
||||
|
||||
resolve(importedConversations);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('Failed to import conversations:', err);
|
||||
toast.error('Import failed', { description: message });
|
||||
reject(new Error(`Import failed: ${message}`));
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports conversations from provided data (without file picker)
|
||||
* @param data - Array of conversation data with messages
|
||||
* @returns Import result with counts
|
||||
*/
|
||||
async importConversationsData(
|
||||
data: ExportedConversations
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
const result = await DatabaseService.importConversations(data);
|
||||
await this.loadConversations();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers file download in browser
|
||||
*/
|
||||
private triggerDownload(data: ExportedConversations, filename?: string): void {
|
||||
const conversation =
|
||||
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
|
||||
|
||||
if (!conversation) {
|
||||
console.error('Invalid data: missing conversation');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationName = conversation.name?.trim() || '';
|
||||
const truncatedSuffix = conversationName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/gi, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.substring(0, 20);
|
||||
const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFilename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const conversationsClient = new ConversationsClient();
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Clients Module - Business Logic Facades
|
||||
*
|
||||
* This module exports all client classes which coordinate business logic:
|
||||
* - MCPClient: MCP connection management and tool execution
|
||||
* - ChatClient: Message operations, streaming, branching
|
||||
* - AgenticClient: Multi-turn tool loop orchestration
|
||||
* - ConversationsClient: Conversation CRUD and message management
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Clients coordinate between Services (stateless API) and Stores (reactive state)
|
||||
* - Clients contain business logic, orchestration, and error handling
|
||||
* - Stores only hold reactive state and delegate to Clients
|
||||
*
|
||||
* @see services/ for stateless API operations
|
||||
* @see stores/ for reactive state
|
||||
*/
|
||||
|
||||
// MCP Client
|
||||
export { MCPClient, mcpClient } from './mcp.client';
|
||||
export type { HealthCheckState, HealthCheckParams } from './mcp.client';
|
||||
|
||||
// Chat Client
|
||||
export { ChatClient, chatClient } from './chat.client';
|
||||
export type { ChatStreamCallbacks, ApiProcessingState, ErrorDialogState } from './chat.client';
|
||||
|
||||
// Agentic Client
|
||||
export { AgenticClient, agenticClient } from './agentic.client';
|
||||
export type {
|
||||
AgenticFlowCallbacks,
|
||||
AgenticFlowOptions,
|
||||
AgenticFlowParams,
|
||||
AgenticFlowResult
|
||||
} from './agentic.client';
|
||||
|
||||
// Conversations Client
|
||||
export { ConversationsClient, conversationsClient } from './conversations.client';
|
||||
|
|
@ -0,0 +1,615 @@
|
|||
/**
|
||||
* MCPClient - Business Logic Facade for MCP Operations
|
||||
*
|
||||
* Implements the "Host" role in MCP architecture, coordinating multiple server
|
||||
* connections and providing a unified interface for tool operations.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **MCPClient** (this class): Business logic facade
|
||||
* - Uses MCPService for low-level protocol operations
|
||||
* - Updates mcpStore with reactive state
|
||||
* - Coordinates multiple server connections
|
||||
* - Aggregates tools from all connected servers
|
||||
* - Routes tool calls to the appropriate server
|
||||
*
|
||||
* - **MCPService**: Stateless protocol layer (transport, connect, callTool)
|
||||
* - **mcpStore**: Reactive state only ($state, getters, setters)
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Lifecycle management (initialize, shutdown)
|
||||
* - Multi-server coordination
|
||||
* - Tool name conflict detection and resolution
|
||||
* - OpenAI-compatible tool definition generation
|
||||
* - Automatic tool-to-server routing
|
||||
* - Health checks
|
||||
* - Usage statistics tracking
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { MCPService, type MCPConnection } from '$lib/services/mcp.service';
|
||||
import type {
|
||||
MCPToolCall,
|
||||
OpenAIToolDefinition,
|
||||
ServerStatus,
|
||||
ToolExecutionResult,
|
||||
MCPClientConfig
|
||||
} from '$lib/types/mcp';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { MCPError } from '$lib/errors';
|
||||
import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/utils/mcp';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
import { detectMcpTransportFromUrl } from '$lib/utils/mcp';
|
||||
|
||||
export type HealthCheckState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'success'; tools: { name: string; description?: string }[] };
|
||||
|
||||
export interface HealthCheckParams {
|
||||
id: string;
|
||||
url: string;
|
||||
requestTimeoutSeconds: number;
|
||||
headers?: string;
|
||||
}
|
||||
|
||||
export class MCPClient {
|
||||
private connections = new Map<string, MCPConnection>();
|
||||
private toolsIndex = new Map<string, string>();
|
||||
private configSignature: string | null = null;
|
||||
private initPromise: Promise<boolean> | null = null;
|
||||
|
||||
private onStateChange?: (state: {
|
||||
isInitializing?: boolean;
|
||||
error?: string | null;
|
||||
toolCount?: number;
|
||||
connectedServers?: string[];
|
||||
}) => void;
|
||||
|
||||
private onHealthCheckChange?: (serverId: string, state: HealthCheckState) => void;
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Store Integration
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sets callback for state changes.
|
||||
* Called by mcpStore to sync reactive state.
|
||||
*/
|
||||
setStateChangeCallback(
|
||||
callback: (state: {
|
||||
isInitializing?: boolean;
|
||||
error?: string | null;
|
||||
toolCount?: number;
|
||||
connectedServers?: string[];
|
||||
}) => void
|
||||
): void {
|
||||
this.onStateChange = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for health check state changes
|
||||
*/
|
||||
setHealthCheckCallback(callback: (serverId: string, state: HealthCheckState) => void): void {
|
||||
this.onHealthCheckChange = callback;
|
||||
}
|
||||
|
||||
private notifyStateChange(state: Parameters<NonNullable<typeof this.onStateChange>>[0]): void {
|
||||
this.onStateChange?.(state);
|
||||
}
|
||||
|
||||
private notifyHealthCheck(serverId: string, state: HealthCheckState): void {
|
||||
this.onHealthCheckChange?.(serverId, state);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Lifecycle
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ensures MCP is initialized with current config.
|
||||
* Handles config changes by reinitializing as needed.
|
||||
* @param perChatOverrides - Optional per-chat MCP server overrides
|
||||
*/
|
||||
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
|
||||
if (!browser) return false;
|
||||
|
||||
const mcpConfig = buildMcpClientConfig(config(), perChatOverrides);
|
||||
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
|
||||
|
||||
if (!signature) {
|
||||
await this.shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isInitialized && this.configSignature === signature) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.initPromise && this.configSignature === signature) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
if (this.connections.size > 0 || this.initPromise) {
|
||||
await this.shutdown();
|
||||
}
|
||||
|
||||
return this.initialize(signature, mcpConfig!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize connections to all configured MCP servers.
|
||||
*/
|
||||
private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise<boolean> {
|
||||
console.log('[MCPClient] Starting initialization...');
|
||||
|
||||
this.notifyStateChange({ isInitializing: true, error: null });
|
||||
this.configSignature = signature;
|
||||
|
||||
const serverEntries = Object.entries(mcpConfig.servers);
|
||||
if (serverEntries.length === 0) {
|
||||
console.log('[MCPClient] No servers configured');
|
||||
this.notifyStateChange({ isInitializing: false, toolCount: 0, connectedServers: [] });
|
||||
return false;
|
||||
}
|
||||
|
||||
this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries);
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInitialize(
|
||||
signature: string,
|
||||
mcpConfig: MCPClientConfig,
|
||||
serverEntries: [string, MCPClientConfig['servers'][string]][]
|
||||
): Promise<boolean> {
|
||||
const clientInfo = mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
|
||||
const capabilities = mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
serverEntries.map(async ([name, serverConfig]) => {
|
||||
const connection = await MCPService.connect(name, serverConfig, clientInfo, capabilities);
|
||||
return { name, connection };
|
||||
})
|
||||
);
|
||||
|
||||
if (this.configSignature !== signature) {
|
||||
console.log('[MCPClient] Config changed during init, aborting');
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
await MCPService.disconnect(result.value.connection).catch(console.warn);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const { name, connection } = result.value;
|
||||
this.connections.set(name, connection);
|
||||
|
||||
for (const tool of connection.tools) {
|
||||
if (this.toolsIndex.has(tool.name)) {
|
||||
console.warn(
|
||||
`[MCPClient] Tool name conflict: "${tool.name}" exists in ` +
|
||||
`"${this.toolsIndex.get(tool.name)}" and "${name}". ` +
|
||||
`Using tool from "${name}".`
|
||||
);
|
||||
}
|
||||
this.toolsIndex.set(tool.name, name);
|
||||
}
|
||||
} else {
|
||||
console.error(`[MCPClient] Failed to connect:`, result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = this.connections.size;
|
||||
const totalCount = serverEntries.length;
|
||||
|
||||
if (successCount === 0 && totalCount > 0) {
|
||||
const error = 'All MCP server connections failed';
|
||||
this.notifyStateChange({
|
||||
isInitializing: false,
|
||||
error,
|
||||
toolCount: 0,
|
||||
connectedServers: []
|
||||
});
|
||||
this.initPromise = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.notifyStateChange({
|
||||
isInitializing: false,
|
||||
error: null,
|
||||
toolCount: this.toolsIndex.size,
|
||||
connectedServers: Array.from(this.connections.keys())
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[MCPClient] Initialization complete: ${successCount}/${totalCount} servers connected, ` +
|
||||
`${this.toolsIndex.size} tools available`
|
||||
);
|
||||
|
||||
this.initPromise = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all MCP connections and clear state.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.initPromise) {
|
||||
await this.initPromise.catch(() => {});
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
if (this.connections.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[MCPClient] Shutting down ${this.connections.size} connections...`);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(this.connections.values()).map((conn) =>
|
||||
MCPService.disconnect(conn).catch((error) => {
|
||||
console.warn(`[MCPClient] Error disconnecting ${conn.serverName}:`, error);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
this.connections.clear();
|
||||
this.toolsIndex.clear();
|
||||
this.configSignature = null;
|
||||
|
||||
this.notifyStateChange({
|
||||
isInitializing: false,
|
||||
error: null,
|
||||
toolCount: 0,
|
||||
connectedServers: []
|
||||
});
|
||||
|
||||
console.log('[MCPClient] Shutdown complete');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Tool Definitions
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns tools in OpenAI function calling format.
|
||||
* Ready to be sent to /v1/chat/completions API.
|
||||
*/
|
||||
getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
|
||||
const tools: OpenAIToolDefinition[] = [];
|
||||
|
||||
for (const connection of this.connections.values()) {
|
||||
for (const tool of connection.tools) {
|
||||
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
|
||||
const normalizedSchema = this.normalizeSchemaProperties(rawSchema);
|
||||
|
||||
tools.push({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: normalizedSchema
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize JSON Schema properties to ensure all have explicit types.
|
||||
* Infers type from default value if missing - fixes compatibility with
|
||||
* llama.cpp which requires explicit types in tool schemas.
|
||||
*/
|
||||
private normalizeSchemaProperties(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== 'object') return schema;
|
||||
|
||||
const normalized = { ...schema };
|
||||
|
||||
if (normalized.properties && typeof normalized.properties === 'object') {
|
||||
const props = normalized.properties as Record<string, Record<string, unknown>>;
|
||||
const normalizedProps: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const [key, prop] of Object.entries(props)) {
|
||||
if (!prop || typeof prop !== 'object') {
|
||||
normalizedProps[key] = prop;
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedProp = { ...prop };
|
||||
|
||||
// Infer type from default if missing
|
||||
if (!normalizedProp.type && normalizedProp.default !== undefined) {
|
||||
const defaultVal = normalizedProp.default;
|
||||
if (typeof defaultVal === 'string') {
|
||||
normalizedProp.type = 'string';
|
||||
} else if (typeof defaultVal === 'number') {
|
||||
normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number';
|
||||
} else if (typeof defaultVal === 'boolean') {
|
||||
normalizedProp.type = 'boolean';
|
||||
} else if (Array.isArray(defaultVal)) {
|
||||
normalizedProp.type = 'array';
|
||||
} else if (typeof defaultVal === 'object' && defaultVal !== null) {
|
||||
normalizedProp.type = 'object';
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedProp.properties) {
|
||||
Object.assign(
|
||||
normalizedProp,
|
||||
this.normalizeSchemaProperties(normalizedProp as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedProp.items && typeof normalizedProp.items === 'object') {
|
||||
normalizedProp.items = this.normalizeSchemaProperties(
|
||||
normalizedProp.items as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
normalizedProps[key] = normalizedProp;
|
||||
}
|
||||
|
||||
normalized.properties = normalizedProps;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Tool Queries
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns names of all available tools.
|
||||
*/
|
||||
getToolNames(): string[] {
|
||||
return Array.from(this.toolsIndex.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool exists.
|
||||
*/
|
||||
hasTool(toolName: string): boolean {
|
||||
return this.toolsIndex.has(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which server provides a specific tool.
|
||||
*/
|
||||
getToolServer(toolName: string): string | undefined {
|
||||
return this.toolsIndex.get(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Tool Execution
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Executes a tool call, automatically routing to the appropriate server.
|
||||
* Accepts the OpenAI-style tool call format.
|
||||
* @param toolCall - Tool call with function name and arguments
|
||||
* @param signal - Optional abort signal
|
||||
* @returns Tool execution result
|
||||
*/
|
||||
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
||||
const toolName = toolCall.function.name;
|
||||
|
||||
const serverName = this.toolsIndex.get(toolName);
|
||||
if (!serverName) {
|
||||
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
|
||||
}
|
||||
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) {
|
||||
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
|
||||
}
|
||||
|
||||
const updatedStats = incrementMcpServerUsage(config(), serverName);
|
||||
settingsStore.updateConfig('mcpServerUsageStats', updatedStats);
|
||||
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a tool by name with arguments object.
|
||||
* Simpler interface for direct tool calls.
|
||||
* @param toolName - Name of the tool to execute
|
||||
* @param args - Tool arguments as key-value pairs
|
||||
* @param signal - Optional abort signal
|
||||
*/
|
||||
async executeToolByName(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolExecutionResult> {
|
||||
const serverName = this.toolsIndex.get(toolName);
|
||||
if (!serverName) {
|
||||
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
|
||||
}
|
||||
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) {
|
||||
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
|
||||
}
|
||||
|
||||
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
|
||||
}
|
||||
|
||||
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof args === 'string') {
|
||||
const trimmed = args.trim();
|
||||
if (trimmed === '') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new MCPError(
|
||||
`Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
|
||||
-32602
|
||||
);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if (error instanceof MCPError) {
|
||||
throw error;
|
||||
}
|
||||
throw new MCPError(
|
||||
`Failed to parse tool arguments as JSON: ${(error as Error).message}`,
|
||||
-32700
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
|
||||
return args;
|
||||
}
|
||||
|
||||
throw new MCPError(`Invalid tool arguments type: ${typeof args}`, -32602);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Health Checks
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
private parseHeaders(headersJson?: string): Record<string, string> | undefined {
|
||||
if (!headersJson?.trim()) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(headersJson);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[MCPClient] Failed to parse custom headers JSON:', headersJson);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health check for a specific server configuration.
|
||||
* Creates a temporary connection to test connectivity and list tools.
|
||||
*/
|
||||
async runHealthCheck(server: HealthCheckParams): Promise<void> {
|
||||
const trimmedUrl = server.url.trim();
|
||||
|
||||
if (!trimmedUrl) {
|
||||
this.notifyHealthCheck(server.id, {
|
||||
status: 'error',
|
||||
message: 'Please enter a server URL first.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifyHealthCheck(server.id, { status: 'loading' });
|
||||
|
||||
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
|
||||
const headers = this.parseHeaders(server.headers);
|
||||
|
||||
try {
|
||||
const connection = await MCPService.connect(
|
||||
server.id,
|
||||
{
|
||||
url: trimmedUrl,
|
||||
transport: detectMcpTransportFromUrl(trimmedUrl),
|
||||
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
|
||||
requestTimeoutMs: timeoutMs,
|
||||
headers
|
||||
},
|
||||
DEFAULT_MCP_CONFIG.clientInfo,
|
||||
DEFAULT_MCP_CONFIG.capabilities
|
||||
);
|
||||
|
||||
const tools = connection.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description
|
||||
}));
|
||||
|
||||
this.notifyHealthCheck(server.id, { status: 'success', tools });
|
||||
await MCPService.disconnect(connection);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
this.notifyHealthCheck(server.id, { status: 'error', message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Status Getters
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return this.connections.size > 0;
|
||||
}
|
||||
|
||||
get connectedServerCount(): number {
|
||||
return this.connections.size;
|
||||
}
|
||||
|
||||
get connectedServerNames(): string[] {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
|
||||
get toolCount(): number {
|
||||
return this.toolsIndex.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all connected servers.
|
||||
*/
|
||||
getServersStatus(): ServerStatus[] {
|
||||
const statuses: ServerStatus[] = [];
|
||||
|
||||
for (const [name, connection] of this.connections) {
|
||||
statuses.push({
|
||||
name,
|
||||
isConnected: true,
|
||||
toolCount: connection.tools.length,
|
||||
error: undefined
|
||||
});
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpClient = new MCPClient();
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
import type {
|
||||
ApiChatCompletionToolCall,
|
||||
ApiChatCompletionToolCallDelta,
|
||||
ApiChatCompletionStreamChunk
|
||||
} from '$lib/types/api';
|
||||
import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat';
|
||||
import { mergeToolCallDeltas, extractModelName } from '$lib/utils/chat-stream';
|
||||
import type { AgenticChatCompletionRequest } from '$lib/types/agentic';
|
||||
|
||||
export type OpenAISseCallbacks = {
|
||||
onChunk?: (chunk: string) => void;
|
||||
onReasoningChunk?: (chunk: string) => void;
|
||||
onToolCallChunk?: (serializedToolCalls: string) => void;
|
||||
onModel?: (model: string) => void;
|
||||
onFirstValidChunk?: () => void;
|
||||
onProcessingUpdate?: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => void;
|
||||
};
|
||||
|
||||
export type OpenAISseTurnResult = {
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls: ApiChatCompletionToolCall[];
|
||||
finishReason?: string | null;
|
||||
timings?: ChatMessageTimings;
|
||||
};
|
||||
|
||||
export type OpenAISseClientOptions = {
|
||||
url: string;
|
||||
buildHeaders?: () => Record<string, string>;
|
||||
};
|
||||
|
||||
export class OpenAISseClient {
|
||||
constructor(private readonly options: OpenAISseClientOptions) {}
|
||||
|
||||
async stream(
|
||||
request: AgenticChatCompletionRequest,
|
||||
callbacks: OpenAISseCallbacks = {},
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<OpenAISseTurnResult> {
|
||||
const response = await fetch(this.options.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.options.buildHeaders?.() ?? {})
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal: abortSignal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `LLM request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('LLM response stream is not available');
|
||||
}
|
||||
|
||||
return this.consumeStream(reader, callbacks, abortSignal);
|
||||
}
|
||||
|
||||
private async consumeStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
callbacks: OpenAISseCallbacks,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<OpenAISseTurnResult> {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let aggregatedContent = '';
|
||||
let aggregatedReasoning = '';
|
||||
let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let hasOpenToolCallBatch = false;
|
||||
let toolCallIndexOffset = 0;
|
||||
let finishReason: string | null | undefined;
|
||||
let lastTimings: ChatMessageTimings | undefined;
|
||||
let modelEmitted = false;
|
||||
let firstValidChunkEmitted = false;
|
||||
|
||||
const finalizeToolCallBatch = () => {
|
||||
if (!hasOpenToolCallBatch) return;
|
||||
toolCallIndexOffset = aggregatedToolCalls.length;
|
||||
hasOpenToolCallBatch = false;
|
||||
};
|
||||
|
||||
const processToolCalls = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
return;
|
||||
}
|
||||
aggregatedToolCalls = mergeToolCallDeltas(
|
||||
aggregatedToolCalls,
|
||||
toolCalls,
|
||||
toolCallIndexOffset
|
||||
);
|
||||
if (aggregatedToolCalls.length === 0) {
|
||||
return;
|
||||
}
|
||||
hasOpenToolCallBatch = true;
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (abortSignal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = line.slice(6);
|
||||
if (payload === '[DONE]' || payload.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk: ApiChatCompletionStreamChunk;
|
||||
try {
|
||||
chunk = JSON.parse(payload) as ApiChatCompletionStreamChunk;
|
||||
} catch (error) {
|
||||
console.error('[Agentic][SSE] Failed to parse chunk:', error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!firstValidChunkEmitted && chunk.object === 'chat.completion.chunk') {
|
||||
firstValidChunkEmitted = true;
|
||||
callbacks.onFirstValidChunk?.();
|
||||
}
|
||||
|
||||
const choice = chunk.choices?.[0];
|
||||
const delta = choice?.delta;
|
||||
finishReason = choice?.finish_reason ?? finishReason;
|
||||
|
||||
if (!modelEmitted) {
|
||||
const chunkModel = extractModelName(chunk);
|
||||
if (chunkModel) {
|
||||
modelEmitted = true;
|
||||
callbacks.onModel?.(chunkModel);
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.timings || chunk.prompt_progress) {
|
||||
callbacks.onProcessingUpdate?.(chunk.timings, chunk.prompt_progress);
|
||||
if (chunk.timings) {
|
||||
lastTimings = chunk.timings;
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.content) {
|
||||
finalizeToolCallBatch();
|
||||
aggregatedContent += delta.content;
|
||||
callbacks.onChunk?.(delta.content);
|
||||
}
|
||||
|
||||
if (delta?.reasoning_content) {
|
||||
finalizeToolCallBatch();
|
||||
aggregatedReasoning += delta.reasoning_content;
|
||||
callbacks.onReasoningChunk?.(delta.reasoning_content);
|
||||
}
|
||||
|
||||
processToolCalls(delta?.tool_calls);
|
||||
}
|
||||
}
|
||||
|
||||
finalizeToolCallBatch();
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
throw error instanceof Error ? error : new Error('LLM stream error');
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return {
|
||||
content: aggregatedContent,
|
||||
reasoningContent: aggregatedReasoning || undefined,
|
||||
toolCalls: aggregatedToolCalls,
|
||||
finishReason,
|
||||
timings: lastTimings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -13,13 +13,18 @@
|
|||
SyntaxHighlightedCode
|
||||
} from '$lib/components/app';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
|
||||
import { Wrench, Loader2 } from '@lucide/svelte';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { AGENTIC_TAGS, AGENTIC_REGEX } from '$lib/constants/agentic';
|
||||
import { formatJsonPretty } from '$lib/utils/formatters';
|
||||
import { decodeBase64 } from '$lib/utils';
|
||||
import type { ChatMessageToolCallTiming } from '$lib/types/chat';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
toolCallTimings?: ChatMessageToolCallTiming[];
|
||||
}
|
||||
|
||||
interface AgenticSection {
|
||||
|
|
@ -30,10 +35,18 @@
|
|||
toolResult?: string;
|
||||
}
|
||||
|
||||
let { content }: Props = $props();
|
||||
let { content, isStreaming = false, toolCallTimings = [] }: Props = $props();
|
||||
|
||||
const sections = $derived(parseAgenticContent(content));
|
||||
|
||||
// Get timing for a specific tool call by index (completed tool calls only)
|
||||
function getToolCallTiming(toolCallIndex: number): ChatMessageToolCallTiming | undefined {
|
||||
return toolCallTimings[toolCallIndex];
|
||||
}
|
||||
|
||||
// Get streaming tool call from reactive store (not from content markers)
|
||||
const streamingToolCall = $derived(isStreaming ? agenticStreamingToolCall() : null);
|
||||
|
||||
let expandedStates: Record<number, boolean> = $state({});
|
||||
|
||||
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
|
||||
|
|
@ -74,12 +87,7 @@
|
|||
|
||||
const toolName = match[1];
|
||||
const toolArgsBase64 = match[2];
|
||||
let toolArgs = '';
|
||||
try {
|
||||
toolArgs = decodeURIComponent(escape(atob(toolArgsBase64)));
|
||||
} catch {
|
||||
toolArgs = toolArgsBase64;
|
||||
}
|
||||
const toolArgs = decodeBase64(toolArgsBase64);
|
||||
const toolResult = match[3].replace(/^\n+|\n+$/g, '');
|
||||
|
||||
sections.push({
|
||||
|
|
@ -112,12 +120,7 @@
|
|||
|
||||
const toolName = pendingMatch[1];
|
||||
const toolArgsBase64 = pendingMatch[2];
|
||||
let toolArgs = '';
|
||||
try {
|
||||
toolArgs = decodeURIComponent(escape(atob(toolArgsBase64)));
|
||||
} catch {
|
||||
toolArgs = toolArgsBase64;
|
||||
}
|
||||
const toolArgs = decodeBase64(toolArgsBase64);
|
||||
// Capture streaming result content (everything after args marker)
|
||||
const streamingResult = (pendingMatch[3] || '').replace(/^\n+|\n+$/g, '');
|
||||
|
||||
|
|
@ -138,23 +141,7 @@
|
|||
}
|
||||
|
||||
const partialArgsBase64 = partialWithNameMatch[2] || '';
|
||||
let partialArgs = '';
|
||||
if (partialArgsBase64) {
|
||||
try {
|
||||
// Try to decode - may fail if incomplete base64
|
||||
partialArgs = decodeURIComponent(escape(atob(partialArgsBase64)));
|
||||
} catch {
|
||||
// If decoding fails, try padding the base64
|
||||
try {
|
||||
const padded =
|
||||
partialArgsBase64 + '=='.slice(0, (4 - (partialArgsBase64.length % 4)) % 4);
|
||||
partialArgs = decodeURIComponent(escape(atob(padded)));
|
||||
} catch {
|
||||
// Show raw base64 if all decoding fails
|
||||
partialArgs = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
const partialArgs = decodeBase64(partialArgsBase64);
|
||||
|
||||
sections.push({
|
||||
type: AgenticSectionType.TOOL_CALL_STREAMING,
|
||||
|
|
@ -214,46 +201,25 @@
|
|||
<div class="agentic-text">
|
||||
<MarkdownContent content={section.content} />
|
||||
</div>
|
||||
{:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
|
||||
<CollapsibleContentBlock
|
||||
open={isExpanded(index, true)}
|
||||
class="my-2"
|
||||
icon={Loader2}
|
||||
iconClass="h-4 w-4 animate-spin"
|
||||
title={section.toolName || 'Tool call'}
|
||||
subtitle="streaming..."
|
||||
onToggle={() => toggleExpanded(index, true)}
|
||||
>
|
||||
<div class="pt-3">
|
||||
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Arguments:</span>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
</div>
|
||||
{#if section.toolArgs}
|
||||
<SyntaxHighlightedCode
|
||||
code={formatJsonPretty(section.toolArgs)}
|
||||
language="json"
|
||||
maxHeight="20rem"
|
||||
class="text-xs"
|
||||
/>
|
||||
{:else}
|
||||
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
|
||||
Receiving arguments...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</CollapsibleContentBlock>
|
||||
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
||||
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
||||
{@const toolIcon = isPending ? Loader2 : Wrench}
|
||||
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
|
||||
{@const toolCallIndex =
|
||||
sections.slice(0, index + 1).filter((s) => s.type === AgenticSectionType.TOOL_CALL).length -
|
||||
1}
|
||||
{@const timing = !isPending ? getToolCallTiming(toolCallIndex) : undefined}
|
||||
<CollapsibleContentBlock
|
||||
open={isExpanded(index, isPending)}
|
||||
class="my-2"
|
||||
icon={toolIcon}
|
||||
iconClass={toolIconClass}
|
||||
title={section.toolName || ''}
|
||||
subtitle={isPending ? 'executing...' : undefined}
|
||||
subtitle={isPending
|
||||
? 'executing...'
|
||||
: timing
|
||||
? `${(timing.duration_ms / 1000).toFixed(2)}s`
|
||||
: undefined}
|
||||
onToggle={() => toggleExpanded(index, isPending)}
|
||||
>
|
||||
{#if section.toolArgs && section.toolArgs !== '{}'}
|
||||
|
|
@ -289,6 +255,37 @@
|
|||
</CollapsibleContentBlock>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if streamingToolCall}
|
||||
<CollapsibleContentBlock
|
||||
open={true}
|
||||
class="my-2"
|
||||
icon={Loader2}
|
||||
iconClass="h-4 w-4 animate-spin"
|
||||
title={streamingToolCall.name || 'Tool call'}
|
||||
subtitle="streaming..."
|
||||
onToggle={() => {}}
|
||||
>
|
||||
<div class="pt-3">
|
||||
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Arguments:</span>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
</div>
|
||||
{#if streamingToolCall.arguments}
|
||||
<SyntaxHighlightedCode
|
||||
code={formatJsonPretty(streamingToolCall.arguments)}
|
||||
language="json"
|
||||
maxHeight="20rem"
|
||||
class="text-xs"
|
||||
/>
|
||||
{:else}
|
||||
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
|
||||
Receiving arguments...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</CollapsibleContentBlock>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
} from '$lib/components/app';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
|
||||
import { isLoading } from '$lib/stores/chat.svelte';
|
||||
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
||||
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
|
||||
import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Check, X } from '@lucide/svelte';
|
||||
|
|
@ -82,25 +83,18 @@
|
|||
thinkingContent
|
||||
}: Props = $props();
|
||||
|
||||
// Check if content contains agentic tool call markers
|
||||
const isAgenticContent = $derived(
|
||||
const hasAgenticMarkers = $derived(
|
||||
messageContent?.includes('<<<AGENTIC_TOOL_CALL_START>>>') ?? false
|
||||
);
|
||||
|
||||
const hasStreamingToolCall = $derived(isChatStreaming() && agenticStreamingToolCall() !== null);
|
||||
const isAgenticContent = $derived(hasAgenticMarkers || hasStreamingToolCall);
|
||||
const processingState = useProcessingState();
|
||||
|
||||
// Local state for raw output toggle (per message)
|
||||
let showRawOutput = $state(false);
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let displayedModel = $derived((): string | null => {
|
||||
if (message.model) {
|
||||
return message.model;
|
||||
}
|
||||
let showRawOutput = $state(false);
|
||||
|
||||
return null;
|
||||
});
|
||||
let displayedModel = $derived(message.model ?? null);
|
||||
|
||||
const { handleModelChange } = useModelChangeValidation({
|
||||
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
|
||||
|
|
@ -108,9 +102,7 @@
|
|||
});
|
||||
|
||||
function handleCopyModel() {
|
||||
const model = displayedModel();
|
||||
|
||||
void copyToClipboard(model ?? '');
|
||||
void copyToClipboard(displayedModel ?? '');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -191,7 +183,11 @@
|
|||
{#if showRawOutput}
|
||||
<pre class="raw-output">{messageContent || ''}</pre>
|
||||
{:else if isAgenticContent}
|
||||
<AgenticContent content={messageContent || ''} />
|
||||
<AgenticContent
|
||||
content={messageContent || ''}
|
||||
isStreaming={isChatStreaming()}
|
||||
toolCallTimings={message.timings?.agentic?.toolCalls}
|
||||
/>
|
||||
{:else}
|
||||
<MarkdownContent content={messageContent || ''} />
|
||||
{/if}
|
||||
|
|
@ -202,17 +198,17 @@
|
|||
{/if}
|
||||
|
||||
<div class="info my-6 grid gap-4">
|
||||
{#if displayedModel()}
|
||||
{#if displayedModel}
|
||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||
{#if isRouter}
|
||||
<ModelsSelector
|
||||
currentModel={displayedModel()}
|
||||
currentModel={displayedModel}
|
||||
onModelChange={handleModelChange}
|
||||
disabled={isLoading()}
|
||||
upToMessageId={message.id}
|
||||
/>
|
||||
{:else}
|
||||
<ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
|
||||
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
||||
{/if}
|
||||
|
||||
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
|
|
@ -221,6 +217,7 @@
|
|||
promptMs={message.timings.prompt_ms}
|
||||
predictedTokens={message.timings.predicted_n}
|
||||
predictedMs={message.timings.predicted_ms}
|
||||
agenticTimings={message.timings.agentic}
|
||||
/>
|
||||
{:else if isLoading() && currentConfig.showMessageStats}
|
||||
{@const liveStats = processingState.getLiveProcessingStats()}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
|
||||
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
|
||||
import { BadgeChatStatistic } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ChatMessageStatsView } from '$lib/enums';
|
||||
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
|
||||
|
||||
interface Props {
|
||||
predictedTokens?: number;
|
||||
predictedMs?: number;
|
||||
promptTokens?: number;
|
||||
promptMs?: number;
|
||||
// Live mode: when true, shows stats during streaming
|
||||
isLive?: boolean;
|
||||
// Whether prompt processing is still in progress
|
||||
isProcessingPrompt?: boolean;
|
||||
// Initial view to show (defaults to READING in live mode)
|
||||
initialView?: ChatMessageStatsView;
|
||||
agenticTimings?: ChatMessageAgenticTimings;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -24,7 +23,8 @@
|
|||
promptMs,
|
||||
isLive = false,
|
||||
isProcessingPrompt = false,
|
||||
initialView = ChatMessageStatsView.GENERATION
|
||||
initialView = ChatMessageStatsView.GENERATION,
|
||||
agenticTimings
|
||||
}: Props = $props();
|
||||
|
||||
let activeView: ChatMessageStatsView = $state(initialView);
|
||||
|
|
@ -80,6 +80,26 @@
|
|||
|
||||
// In live mode, generation tab is disabled until we have generation stats
|
||||
let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
|
||||
|
||||
let hasAgenticStats = $derived(agenticTimings !== undefined && agenticTimings.toolCallsCount > 0);
|
||||
|
||||
let agenticToolsPerSecond = $derived(
|
||||
hasAgenticStats && agenticTimings!.toolsMs > 0
|
||||
? (agenticTimings!.toolCallsCount / agenticTimings!.toolsMs) * 1000
|
||||
: 0
|
||||
);
|
||||
|
||||
let agenticToolsTimeInSeconds = $derived(
|
||||
hasAgenticStats ? (agenticTimings!.toolsMs / 1000).toFixed(2) : '0.00'
|
||||
);
|
||||
|
||||
let agenticTotalTimeMs = $derived(
|
||||
hasAgenticStats
|
||||
? agenticTimings!.toolsMs + agenticTimings!.llm.predicted_ms + agenticTimings!.llm.prompt_ms
|
||||
: 0
|
||||
);
|
||||
|
||||
let agenticTotalTimeInSeconds = $derived((agenticTotalTimeMs / 1000).toFixed(2));
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-center text-xs text-muted-foreground">
|
||||
|
|
@ -129,6 +149,44 @@
|
|||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{#if hasAgenticStats}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
|
||||
ChatMessageStatsView.TOOLS
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:text-foreground'}"
|
||||
onclick={() => (activeView = ChatMessageStatsView.TOOLS)}
|
||||
>
|
||||
<Wrench class="h-3 w-3" />
|
||||
<span class="sr-only">Tools</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Tool calls</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
|
||||
ChatMessageStatsView.SUMMARY
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:text-foreground'}"
|
||||
onclick={() => (activeView = ChatMessageStatsView.SUMMARY)}
|
||||
>
|
||||
<Layers class="h-3 w-3" />
|
||||
<span class="sr-only">Summary</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Agentic summary</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
|
|
@ -148,9 +206,47 @@
|
|||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
value="{tokensPerSecond.toFixed(2)} tokens/s"
|
||||
value="{tokensPerSecond.toFixed(2)} t/s"
|
||||
tooltipLabel="Generation speed"
|
||||
/>
|
||||
{:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Wrench}
|
||||
value="{agenticTimings!.toolCallsCount} calls"
|
||||
tooltipLabel="Tool calls executed"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value="{agenticToolsTimeInSeconds}s"
|
||||
tooltipLabel="Tool execution time"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
|
||||
tooltipLabel="Tool execution rate"
|
||||
/>
|
||||
{:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Layers}
|
||||
value="{agenticTimings!.turns} turns"
|
||||
tooltipLabel="Agentic turns (LLM calls)"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={WholeWord}
|
||||
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
|
||||
tooltipLabel="Total tokens generated"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value="{agenticTotalTimeInSeconds}s"
|
||||
tooltipLabel="Total time (LLM + tools)"
|
||||
/>
|
||||
{:else if hasPromptStats}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { parseMcpServerSettings, parseMcpServerUsageStats } from '$lib/config/mcp';
|
||||
import { parseMcpServerSettings, parseMcpServerUsageStats } from '$lib/utils/mcp';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
|
||||
import {
|
||||
mcpGetHealthCheckState,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { Plus, X } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { parseMcpServerSettings } from '$lib/config/mcp';
|
||||
import { parseMcpServerSettings } from '$lib/utils/mcp';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@
|
|||
: 'text-muted-foreground',
|
||||
isOpen ? 'text-foreground' : ''
|
||||
)}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 24rem)"
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 20rem)"
|
||||
disabled={disabled || updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import { hasEnabledMcpServers } from './mcp';
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
import type { AgenticConfig } from '$lib/types/agentic';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
|
||||
import { normalizePositiveNumber } from '$lib/utils/number';
|
||||
|
||||
/**
|
||||
* Gets the current agentic configuration.
|
||||
* Automatically disables agentic mode if no MCP servers are configured.
|
||||
* @param settings - Global settings configuration
|
||||
* @param perChatOverrides - Optional per-chat MCP server overrides
|
||||
*/
|
||||
export function getAgenticConfig(
|
||||
settings: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): AgenticConfig {
|
||||
const maxTurns = normalizePositiveNumber(
|
||||
settings.agenticMaxTurns,
|
||||
DEFAULT_AGENTIC_CONFIG.maxTurns
|
||||
);
|
||||
const maxToolPreviewLines = normalizePositiveNumber(
|
||||
settings.agenticMaxToolPreviewLines,
|
||||
DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines
|
||||
);
|
||||
const filterReasoningAfterFirstTurn =
|
||||
typeof settings.agenticFilterReasoningAfterFirstTurn === 'boolean'
|
||||
? settings.agenticFilterReasoningAfterFirstTurn
|
||||
: DEFAULT_AGENTIC_CONFIG.filterReasoningAfterFirstTurn;
|
||||
|
||||
return {
|
||||
enabled: hasEnabledMcpServers(settings, perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
maxTurns,
|
||||
maxToolPreviewLines,
|
||||
filterReasoningAfterFirstTurn
|
||||
};
|
||||
}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
import type {
|
||||
MCPClientConfig,
|
||||
MCPServerConfig,
|
||||
MCPServerSettingsEntry,
|
||||
McpServerUsageStats
|
||||
} from '$lib/types/mcp';
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
import { detectMcpTransportFromUrl, generateMcpServerId } from '$lib/utils/mcp';
|
||||
import { normalizePositiveNumber } from '$lib/utils/number';
|
||||
|
||||
export function parseMcpServerSettings(
|
||||
rawServers: unknown,
|
||||
fallbackRequestTimeoutSeconds = DEFAULT_MCP_CONFIG.requestTimeoutSeconds
|
||||
): MCPServerSettingsEntry[] {
|
||||
if (!rawServers) return [];
|
||||
|
||||
let parsed: unknown;
|
||||
if (typeof rawServers === 'string') {
|
||||
const trimmed = rawServers.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
parsed = rawServers;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.map((entry, index) => {
|
||||
const requestTimeoutSeconds = normalizePositiveNumber(
|
||||
(entry as { requestTimeoutSeconds?: unknown })?.requestTimeoutSeconds,
|
||||
fallbackRequestTimeoutSeconds
|
||||
);
|
||||
|
||||
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
|
||||
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
|
||||
|
||||
return {
|
||||
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
|
||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||
url,
|
||||
requestTimeoutSeconds,
|
||||
headers: headers || undefined
|
||||
} satisfies MCPServerSettingsEntry;
|
||||
});
|
||||
}
|
||||
|
||||
function buildServerConfig(
|
||||
entry: MCPServerSettingsEntry,
|
||||
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
|
||||
): MCPServerConfig | undefined {
|
||||
if (!entry?.url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse custom headers if provided
|
||||
let headers: Record<string, string> | undefined;
|
||||
if (entry.headers) {
|
||||
try {
|
||||
const parsed = JSON.parse(entry.headers);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
headers = parsed as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[MCP] Failed to parse custom headers JSON, ignoring:', entry.headers);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: entry.url,
|
||||
transport: detectMcpTransportFromUrl(entry.url),
|
||||
handshakeTimeoutMs: connectionTimeoutMs,
|
||||
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
|
||||
headers
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a server is enabled considering per-chat overrides.
|
||||
* Per-chat override takes precedence over global setting.
|
||||
*/
|
||||
function isServerEnabled(
|
||||
server: MCPServerSettingsEntry,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): boolean {
|
||||
if (perChatOverrides) {
|
||||
const override = perChatOverrides.find((o) => o.serverId === server.id);
|
||||
if (override !== undefined) {
|
||||
return override.enabled;
|
||||
}
|
||||
}
|
||||
return server.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds MCP client configuration from settings.
|
||||
* Returns undefined if no valid servers are configured.
|
||||
* @param config - Global settings configuration
|
||||
* @param perChatOverrides - Optional per-chat server overrides
|
||||
*/
|
||||
export function buildMcpClientConfig(
|
||||
config: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): MCPClientConfig | undefined {
|
||||
const rawServers = parseMcpServerSettings(config.mcpServers);
|
||||
|
||||
if (!rawServers.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const servers: Record<string, MCPServerConfig> = {};
|
||||
for (const [index, entry] of rawServers.entries()) {
|
||||
if (!isServerEnabled(entry, perChatOverrides)) continue;
|
||||
|
||||
const normalized = buildServerConfig(entry);
|
||||
if (normalized) {
|
||||
servers[generateMcpServerId(entry.id, index)] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(servers).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
|
||||
capabilities: DEFAULT_MCP_CONFIG.capabilities,
|
||||
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
|
||||
requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
|
||||
servers
|
||||
};
|
||||
}
|
||||
|
||||
export function hasEnabledMcpServers(
|
||||
config: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): boolean {
|
||||
return Boolean(buildMcpClientConfig(config, perChatOverrides));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP Server Usage Stats
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse MCP server usage stats from settings.
|
||||
*/
|
||||
export function parseMcpServerUsageStats(rawStats: unknown): McpServerUsageStats {
|
||||
if (!rawStats) return {};
|
||||
|
||||
if (typeof rawStats === 'string') {
|
||||
const trimmed = rawStats.trim();
|
||||
if (!trimmed) return {};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as McpServerUsageStats;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[MCP] Failed to parse mcpServerUsageStats JSON, ignoring value');
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage count for a specific server.
|
||||
*/
|
||||
export function getMcpServerUsageCount(config: SettingsConfigType, serverId: string): number {
|
||||
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
|
||||
return stats[serverId] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment usage count for a server and return updated stats JSON.
|
||||
*/
|
||||
export function incrementMcpServerUsage(config: SettingsConfigType, serverId: string): string {
|
||||
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
|
||||
stats[serverId] = (stats[serverId] || 0) + 1;
|
||||
return JSON.stringify(stats);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
export enum ChatMessageStatsView {
|
||||
GENERATION = 'generation',
|
||||
READING = 'reading'
|
||||
READING = 'reading',
|
||||
TOOLS = 'tools',
|
||||
SUMMARY = 'summary'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ export function useProcessingState(): UseProcessingStateReturn {
|
|||
}
|
||||
|
||||
if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
|
||||
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} tokens/sec`);
|
||||
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} t/s`);
|
||||
}
|
||||
|
||||
if (stateToUse.speculative) {
|
||||
|
|
|
|||
|
|
@ -1,408 +0,0 @@
|
|||
/**
|
||||
* MCPHostManager - Multi-server MCP connection aggregator
|
||||
*
|
||||
* Implements the "Host" role in MCP architecture, coordinating multiple server
|
||||
* connections and providing a unified interface for tool operations.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **MCPHostManager** (this class): Host-level coordination layer
|
||||
* - Coordinates multiple Client instances (MCPServerConnection)
|
||||
* - Aggregates tools from all connected servers
|
||||
* - Routes tool calls to the appropriate server
|
||||
* - Manages lifecycle of all connections
|
||||
*
|
||||
* - **MCPServerConnection**: Individual server connection wrapper
|
||||
* - **agenticStore**: Uses MCPHostManager for tool execution in agentic loops
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Parallel server initialization and shutdown
|
||||
* - Tool name conflict detection and resolution
|
||||
* - OpenAI-compatible tool definition generation
|
||||
* - Automatic tool-to-server routing
|
||||
*/
|
||||
|
||||
import { MCPServerConnection } from './server-connection';
|
||||
import type { ToolExecutionResult } from '$lib/types/mcp';
|
||||
import type {
|
||||
MCPToolCall,
|
||||
MCPHostManagerConfig,
|
||||
OpenAIToolDefinition,
|
||||
ServerStatus
|
||||
} from '$lib/types/mcp';
|
||||
import { MCPError } from '$lib/errors';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
/**
|
||||
* Manages multiple MCP server connections and provides unified tool access.
|
||||
*/
|
||||
export class MCPHostManager {
|
||||
private connections = new Map<string, MCPServerConnection>();
|
||||
private toolsIndex = new Map<string, string>(); // toolName → serverName
|
||||
private _isInitialized = false;
|
||||
private _initializationError: Error | null = null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async initialize(config: MCPHostManagerConfig): Promise<void> {
|
||||
console.log('[MCPHost] Starting initialization...');
|
||||
|
||||
// Clean up previous connections
|
||||
await this.shutdown();
|
||||
|
||||
const serverEntries = Object.entries(config.servers);
|
||||
if (serverEntries.length === 0) {
|
||||
console.log('[MCPHost] No servers configured');
|
||||
this._isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to each server in parallel
|
||||
const connectionPromises = serverEntries.map(async ([name, serverConfig]) => {
|
||||
try {
|
||||
const connection = new MCPServerConnection({
|
||||
name,
|
||||
server: serverConfig,
|
||||
clientInfo: config.clientInfo,
|
||||
capabilities: config.capabilities
|
||||
});
|
||||
|
||||
await connection.connect();
|
||||
return { name, connection, success: true, error: null };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[MCPHost] Failed to connect to ${name}:`, errorMessage);
|
||||
return { name, connection: null, success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(connectionPromises);
|
||||
|
||||
// Store successful connections
|
||||
for (const result of results) {
|
||||
if (result.success && result.connection) {
|
||||
this.connections.set(result.name, result.connection);
|
||||
}
|
||||
}
|
||||
|
||||
// Build tools index
|
||||
this.rebuildToolsIndex();
|
||||
|
||||
const successCount = this.connections.size;
|
||||
const totalCount = serverEntries.length;
|
||||
|
||||
if (successCount === 0 && totalCount > 0) {
|
||||
this._initializationError = new Error('All MCP server connections failed');
|
||||
throw this._initializationError;
|
||||
}
|
||||
|
||||
this._isInitialized = true;
|
||||
this._initializationError = null;
|
||||
|
||||
console.log(
|
||||
`[MCPHost] Initialization complete: ${successCount}/${totalCount} servers connected, ` +
|
||||
`${this.toolsIndex.size} tools available`
|
||||
);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.connections.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[MCPHost] Shutting down ${this.connections.size} connections...`);
|
||||
|
||||
const shutdownPromises = Array.from(this.connections.values()).map((conn) =>
|
||||
conn.disconnect().catch((error) => {
|
||||
console.warn(`[MCPHost] Error disconnecting ${conn.serverName}:`, error);
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(shutdownPromises);
|
||||
|
||||
this.connections.clear();
|
||||
this.toolsIndex.clear();
|
||||
this._isInitialized = false;
|
||||
|
||||
console.log('[MCPHost] Shutdown complete');
|
||||
}
|
||||
|
||||
private rebuildToolsIndex(): void {
|
||||
this.toolsIndex.clear();
|
||||
|
||||
for (const [serverName, connection] of this.connections) {
|
||||
for (const tool of connection.tools) {
|
||||
// Check for name conflicts
|
||||
if (this.toolsIndex.has(tool.name)) {
|
||||
console.warn(
|
||||
`[MCPHost] Tool name conflict: "${tool.name}" exists in ` +
|
||||
`"${this.toolsIndex.get(tool.name)}" and "${serverName}". ` +
|
||||
`Using tool from "${serverName}".`
|
||||
);
|
||||
}
|
||||
this.toolsIndex.set(tool.name, serverName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Tool Aggregation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns ALL tools from ALL connected servers.
|
||||
* This is what we send to LLM as available tools.
|
||||
*/
|
||||
getAllTools(): Tool[] {
|
||||
const allTools: Tool[] = [];
|
||||
for (const connection of this.connections.values()) {
|
||||
allTools.push(...connection.tools);
|
||||
}
|
||||
return allTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize JSON Schema properties to ensure all have explicit types.
|
||||
* Infers type from default value if missing - fixes compatibility with
|
||||
* llama.cpp which requires explicit types in tool schemas.
|
||||
*/
|
||||
private normalizeSchemaProperties(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== 'object') return schema;
|
||||
|
||||
const normalized = { ...schema };
|
||||
|
||||
// Process properties object
|
||||
if (normalized.properties && typeof normalized.properties === 'object') {
|
||||
const props = normalized.properties as Record<string, Record<string, unknown>>;
|
||||
const normalizedProps: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const [key, prop] of Object.entries(props)) {
|
||||
if (!prop || typeof prop !== 'object') {
|
||||
normalizedProps[key] = prop;
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedProp = { ...prop };
|
||||
|
||||
// Infer type from default if missing
|
||||
if (!normalizedProp.type && normalizedProp.default !== undefined) {
|
||||
const defaultVal = normalizedProp.default;
|
||||
if (typeof defaultVal === 'string') {
|
||||
normalizedProp.type = 'string';
|
||||
} else if (typeof defaultVal === 'number') {
|
||||
normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number';
|
||||
} else if (typeof defaultVal === 'boolean') {
|
||||
normalizedProp.type = 'boolean';
|
||||
} else if (Array.isArray(defaultVal)) {
|
||||
normalizedProp.type = 'array';
|
||||
} else if (typeof defaultVal === 'object' && defaultVal !== null) {
|
||||
normalizedProp.type = 'object';
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively normalize nested schemas
|
||||
if (normalizedProp.properties) {
|
||||
Object.assign(
|
||||
normalizedProp,
|
||||
this.normalizeSchemaProperties(normalizedProp as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize items in array schemas
|
||||
if (normalizedProp.items && typeof normalizedProp.items === 'object') {
|
||||
normalizedProp.items = this.normalizeSchemaProperties(
|
||||
normalizedProp.items as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
normalizedProps[key] = normalizedProp;
|
||||
}
|
||||
|
||||
normalized.properties = normalizedProps;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns tools in OpenAI function calling format.
|
||||
* Ready to be sent to /v1/chat/completions API.
|
||||
*/
|
||||
getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
|
||||
return this.getAllTools().map((tool) => {
|
||||
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
|
||||
// Normalize schema to fix missing types
|
||||
const normalizedSchema = this.normalizeSchemaProperties(rawSchema);
|
||||
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: normalizedSchema
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns names of all available tools.
|
||||
*/
|
||||
getToolNames(): string[] {
|
||||
return Array.from(this.toolsIndex.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool exists.
|
||||
*/
|
||||
hasTool(toolName: string): boolean {
|
||||
return this.toolsIndex.has(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which server provides a specific tool.
|
||||
*/
|
||||
getToolServer(toolName: string): string | undefined {
|
||||
return this.toolsIndex.get(toolName);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Tool Execution
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a tool call, automatically routing to the appropriate server.
|
||||
* Accepts the OpenAI-style tool call format.
|
||||
*/
|
||||
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
||||
const toolName = toolCall.function.name;
|
||||
|
||||
// Find which server handles this tool
|
||||
const serverName = this.toolsIndex.get(toolName);
|
||||
if (!serverName) {
|
||||
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
|
||||
}
|
||||
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) {
|
||||
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
|
||||
}
|
||||
|
||||
// Parse arguments
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
|
||||
// Delegate to the appropriate server
|
||||
return connection.callTool({ name: toolName, arguments: args }, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool by name with arguments object.
|
||||
* Simpler interface for direct tool calls.
|
||||
*/
|
||||
async executeToolByName(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolExecutionResult> {
|
||||
const serverName = this.toolsIndex.get(toolName);
|
||||
if (!serverName) {
|
||||
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
|
||||
}
|
||||
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) {
|
||||
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
|
||||
}
|
||||
|
||||
return connection.callTool({ name: toolName, arguments: args }, signal);
|
||||
}
|
||||
|
||||
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof args === 'string') {
|
||||
const trimmed = args.trim();
|
||||
if (trimmed === '') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new MCPError(
|
||||
`Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
|
||||
-32602
|
||||
);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if (error instanceof MCPError) {
|
||||
throw error;
|
||||
}
|
||||
throw new MCPError(
|
||||
`Failed to parse tool arguments as JSON: ${(error as Error).message}`,
|
||||
-32700
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
|
||||
return args;
|
||||
}
|
||||
|
||||
throw new MCPError(`Invalid tool arguments type: ${typeof args}`, -32602);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return this._isInitialized;
|
||||
}
|
||||
|
||||
get initializationError(): Error | null {
|
||||
return this._initializationError;
|
||||
}
|
||||
|
||||
get connectedServerCount(): number {
|
||||
return this.connections.size;
|
||||
}
|
||||
|
||||
get connectedServerNames(): string[] {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
|
||||
get toolCount(): number {
|
||||
return this.toolsIndex.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all configured servers.
|
||||
*/
|
||||
getServersStatus(): ServerStatus[] {
|
||||
const statuses: ServerStatus[] = [];
|
||||
|
||||
for (const [name, connection] of this.connections) {
|
||||
statuses.push({
|
||||
name,
|
||||
isConnected: connection.isConnected,
|
||||
toolCount: connection.tools.length,
|
||||
error: connection.lastError?.message
|
||||
});
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific server connection (for advanced use cases).
|
||||
*/
|
||||
getServerConnection(name: string): MCPServerConnection | undefined {
|
||||
return this.connections.get(name);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
// New architecture exports
|
||||
export { MCPHostManager } from './host-manager';
|
||||
export { MCPServerConnection } from './server-connection';
|
||||
|
||||
// Errors
|
||||
export { MCPError } from '$lib/errors';
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
/**
|
||||
* MCPServerConnection - Single MCP server connection wrapper
|
||||
*
|
||||
* Wraps the MCP SDK Client for a single server connection. Each instance represents
|
||||
* one isolated connection with its own transport, capabilities, and lifecycle.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - One MCPServerConnection = one connection = one SDK Client
|
||||
* - Server isolation - each has its own transport and capabilities
|
||||
* - Independent lifecycle (connect, disconnect)
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Transport creation (WebSocket, StreamableHTTP, SSE)
|
||||
* - Tool discovery and caching
|
||||
* - Tool execution with abort support
|
||||
* - Connection state management
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type {
|
||||
MCPServerConnectionConfig,
|
||||
ToolCallParams,
|
||||
ToolExecutionResult
|
||||
} from '$lib/types/mcp';
|
||||
import { MCPError } from '$lib/errors';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
|
||||
// Type for tool call result content item
|
||||
interface ToolResultContentItem {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
resource?: { text?: string; blob?: string; uri?: string };
|
||||
}
|
||||
|
||||
// Type for tool call result
|
||||
interface ToolCallResult {
|
||||
content?: ToolResultContentItem[];
|
||||
isError?: boolean;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the MCP SDK Client and provides a clean interface for tool operations.
|
||||
*/
|
||||
export class MCPServerConnection {
|
||||
private client: Client;
|
||||
private transport: Transport | null = null;
|
||||
private _tools: Tool[] = [];
|
||||
private _isConnected = false;
|
||||
private _lastError: Error | null = null;
|
||||
|
||||
readonly serverName: string;
|
||||
readonly config: MCPServerConnectionConfig;
|
||||
|
||||
constructor(config: MCPServerConnectionConfig) {
|
||||
this.serverName = config.name;
|
||||
this.config = config;
|
||||
|
||||
const clientInfo = config.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
|
||||
const capabilities = config.capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
|
||||
|
||||
// Create SDK Client with our host info
|
||||
this.client = new Client(
|
||||
{
|
||||
name: clientInfo.name,
|
||||
version: clientInfo.version ?? '1.0.0'
|
||||
},
|
||||
{ capabilities }
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this._isConnected) {
|
||||
console.log(`[MCP][${this.serverName}] Already connected`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[MCP][${this.serverName}] Creating transport...`);
|
||||
this.transport = await this.createTransport();
|
||||
|
||||
console.log(`[MCP][${this.serverName}] Connecting to server...`);
|
||||
// SDK Client.connect() performs:
|
||||
// 1. initialize request → server
|
||||
// 2. Receives server capabilities
|
||||
// 3. Sends initialized notification
|
||||
await this.client.connect(this.transport);
|
||||
|
||||
console.log(`[MCP][${this.serverName}] Connected, listing tools...`);
|
||||
await this.refreshTools();
|
||||
|
||||
this._isConnected = true;
|
||||
this._lastError = null;
|
||||
console.log(
|
||||
`[MCP][${this.serverName}] Initialization complete with ${this._tools.length} tools`
|
||||
);
|
||||
} catch (error) {
|
||||
this._lastError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error(`[MCP][${this.serverName}] Connection failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this._isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[MCP][${this.serverName}] Disconnecting...`);
|
||||
try {
|
||||
await this.client.close();
|
||||
} catch (error) {
|
||||
console.warn(`[MCP][${this.serverName}] Error during disconnect:`, error);
|
||||
}
|
||||
|
||||
this._isConnected = false;
|
||||
this._tools = [];
|
||||
this.transport = null;
|
||||
}
|
||||
|
||||
private async createTransport(): Promise<Transport> {
|
||||
const serverConfig = this.config.server;
|
||||
|
||||
if (!serverConfig.url) {
|
||||
throw new Error('MCP server configuration is missing url');
|
||||
}
|
||||
|
||||
const url = new URL(serverConfig.url);
|
||||
const requestInit: RequestInit = {};
|
||||
|
||||
if (serverConfig.headers) {
|
||||
requestInit.headers = serverConfig.headers;
|
||||
}
|
||||
if (serverConfig.credentials) {
|
||||
requestInit.credentials = serverConfig.credentials;
|
||||
}
|
||||
|
||||
if (serverConfig.transport === 'websocket') {
|
||||
console.log(`[MCP][${this.serverName}] Using WebSocket transport...`);
|
||||
return new WebSocketClientTransport(url);
|
||||
}
|
||||
|
||||
// Try StreamableHTTP first (modern), fall back to SSE (legacy)
|
||||
try {
|
||||
console.log(`[MCP][${this.serverName}] Trying StreamableHTTP transport...`);
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
requestInit,
|
||||
sessionId: serverConfig.sessionId
|
||||
});
|
||||
return transport;
|
||||
} catch (httpError) {
|
||||
console.warn(
|
||||
`[MCP][${this.serverName}] StreamableHTTP failed, trying SSE transport...`,
|
||||
httpError
|
||||
);
|
||||
|
||||
try {
|
||||
const transport = new SSEClientTransport(url, {
|
||||
requestInit
|
||||
});
|
||||
return transport;
|
||||
} catch (sseError) {
|
||||
const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
|
||||
const sseMsg = sseError instanceof Error ? sseError.message : String(sseError);
|
||||
throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Tool Discovery
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async refreshTools(): Promise<void> {
|
||||
try {
|
||||
const toolsResult = await this.client.listTools();
|
||||
this._tools = toolsResult.tools ?? [];
|
||||
} catch (error) {
|
||||
console.warn(`[MCP][${this.serverName}] Failed to list tools:`, error);
|
||||
this._tools = [];
|
||||
}
|
||||
}
|
||||
|
||||
get tools(): Tool[] {
|
||||
return this._tools;
|
||||
}
|
||||
|
||||
get toolNames(): string[] {
|
||||
return this._tools.map((t) => t.name);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Tool Execution
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async callTool(params: ToolCallParams, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
||||
if (!this._isConnected) {
|
||||
throw new MCPError(`Server ${this.serverName} is not connected`, -32000);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.client.callTool(
|
||||
{ name: params.name, arguments: params.arguments },
|
||||
undefined,
|
||||
{ signal }
|
||||
);
|
||||
|
||||
return {
|
||||
content: this.formatToolResult(result as ToolCallResult),
|
||||
isError: (result as ToolCallResult).isError ?? false
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new MCPError(`Tool execution failed: ${message}`, -32603);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._isConnected;
|
||||
}
|
||||
|
||||
get lastError(): Error | null {
|
||||
return this._lastError;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Formatting
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private formatToolResult(result: ToolCallResult): string {
|
||||
const content = result.content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((item) => this.formatSingleContent(item))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private formatSingleContent(content: ToolResultContentItem): string {
|
||||
if (content.type === 'text' && content.text) {
|
||||
return content.text;
|
||||
}
|
||||
if (content.type === 'image' && content.data) {
|
||||
return `data:${content.mimeType ?? 'image/png'};base64,${content.data}`;
|
||||
}
|
||||
if (content.type === 'resource' && content.resource) {
|
||||
const resource = content.resource;
|
||||
if (resource.text) {
|
||||
return resource.text;
|
||||
}
|
||||
if (resource.blob) {
|
||||
return resource.blob;
|
||||
}
|
||||
return JSON.stringify(resource);
|
||||
}
|
||||
// audio type
|
||||
if (content.data && content.mimeType) {
|
||||
return `data:${content.mimeType};base64,${content.data}`;
|
||||
}
|
||||
return JSON.stringify(content);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,9 +34,13 @@ import { AttachmentType } from '$lib/enums';
|
|||
* - Request lifecycle management (abort via AbortSignal)
|
||||
*/
|
||||
export class ChatService {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Messaging
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Messaging
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sends a chat completion request to the llama.cpp server.
|
||||
|
|
@ -63,6 +67,8 @@ export class ChatService {
|
|||
onToolCallChunk,
|
||||
onModel,
|
||||
onTimings,
|
||||
// Tools for function calling
|
||||
tools,
|
||||
// Generation parameters
|
||||
temperature,
|
||||
max_tokens,
|
||||
|
|
@ -116,10 +122,13 @@ export class ChatService {
|
|||
const requestBody: ApiChatCompletionRequest = {
|
||||
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
content: msg.content,
|
||||
tool_calls: msg.tool_calls,
|
||||
tool_call_id: msg.tool_call_id
|
||||
})),
|
||||
stream,
|
||||
return_progress: stream ? true : undefined
|
||||
return_progress: stream ? true : undefined,
|
||||
tools: tools && tools.length > 0 ? tools : undefined
|
||||
};
|
||||
|
||||
// Include model in request if provided (required in ROUTER mode)
|
||||
|
|
@ -247,9 +256,13 @@ export class ChatService {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Streaming
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Streaming
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles streaming response from the chat completion API
|
||||
|
|
@ -309,6 +322,8 @@ export class ChatService {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log('[ChatService] Tool call delta received:', JSON.stringify(toolCalls));
|
||||
|
||||
aggregatedToolCalls = ChatService.mergeToolCallDeltas(
|
||||
aggregatedToolCalls,
|
||||
toolCalls,
|
||||
|
|
@ -323,6 +338,8 @@ export class ChatService {
|
|||
|
||||
const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
|
||||
|
||||
console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
|
||||
|
||||
if (!serializedToolCalls) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -563,9 +580,13 @@ export class ChatService {
|
|||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Conversion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Conversion
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts a database message with attachments to API chat message format.
|
||||
|
|
@ -677,9 +698,13 @@ export class ChatService {
|
|||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Utilities
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parses error response and creates appropriate error with context information
|
||||
|
|
@ -66,9 +66,13 @@ import { v4 as uuid } from 'uuid';
|
|||
* `currNode` tracks the currently active branch endpoint.
|
||||
*/
|
||||
export class DatabaseService {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Conversations
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Conversations
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new conversation.
|
||||
|
|
@ -88,9 +92,13 @@ export class DatabaseService {
|
|||
return conversation;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Messages
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Messages
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new message branch by adding a message and updating parent/child relationships.
|
||||
|
|
@ -328,9 +336,13 @@ export class DatabaseService {
|
|||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Navigation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Navigation
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Updates the conversation's current node (active branch).
|
||||
|
|
@ -359,9 +371,13 @@ export class DatabaseService {
|
|||
await db.messages.update(id, updates);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Import
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Imports multiple conversations and their messages.
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export { ChatService } from './chat';
|
||||
export { DatabaseService } from './database';
|
||||
export { ModelsService } from './models';
|
||||
export { PropsService } from './props';
|
||||
export { ParameterSyncService } from './parameter-sync';
|
||||
export { ChatService } from './chat.service';
|
||||
export { DatabaseService } from './database.service';
|
||||
export { ModelsService } from './models.service';
|
||||
export { PropsService } from './props.service';
|
||||
export { ParameterSyncService } from './parameter-sync.service';
|
||||
export { MCPService, type MCPConnection } from './mcp.service';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* MCPService - Stateless MCP Protocol Communication Layer
|
||||
*
|
||||
* Low-level MCP operations:
|
||||
* - Transport creation (WebSocket, StreamableHTTP, SSE)
|
||||
* - Server connection/disconnection
|
||||
* - Tool discovery (listTools)
|
||||
* - Tool execution (callTool)
|
||||
*
|
||||
* NO business logic, NO state management, NO orchestration.
|
||||
* This is the protocol layer - pure MCP SDK operations.
|
||||
*
|
||||
* @see MCPClient in clients/mcp/ for business logic facade
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
ToolCallParams,
|
||||
ToolExecutionResult,
|
||||
Implementation,
|
||||
ClientCapabilities
|
||||
} from '$lib/types/mcp';
|
||||
import { MCPError } from '$lib/errors';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
|
||||
/**
|
||||
* Represents an active MCP server connection.
|
||||
* Returned by MCPService.connect() and used for subsequent operations.
|
||||
*/
|
||||
export interface MCPConnection {
|
||||
/** MCP SDK Client instance */
|
||||
client: Client;
|
||||
/** Active transport */
|
||||
transport: Transport;
|
||||
/** Discovered tools from this server */
|
||||
tools: Tool[];
|
||||
/** Server identifier */
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
interface ToolResultContentItem {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
resource?: { text?: string; blob?: string; uri?: string };
|
||||
}
|
||||
|
||||
interface ToolCallResult {
|
||||
content?: ToolResultContentItem[];
|
||||
isError?: boolean;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class MCPService {
|
||||
/**
|
||||
* Create transport based on server configuration.
|
||||
* Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports.
|
||||
*/
|
||||
static createTransport(config: MCPServerConfig): Transport {
|
||||
if (!config.url) {
|
||||
throw new Error('MCP server configuration is missing url');
|
||||
}
|
||||
|
||||
const url = new URL(config.url);
|
||||
const requestInit: RequestInit = {};
|
||||
|
||||
if (config.headers) {
|
||||
requestInit.headers = config.headers;
|
||||
}
|
||||
if (config.credentials) {
|
||||
requestInit.credentials = config.credentials;
|
||||
}
|
||||
|
||||
if (config.transport === 'websocket') {
|
||||
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
|
||||
return new WebSocketClientTransport(url);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
|
||||
return new StreamableHTTPClientTransport(url, {
|
||||
requestInit,
|
||||
sessionId: config.sessionId
|
||||
});
|
||||
} catch (httpError) {
|
||||
console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError);
|
||||
|
||||
try {
|
||||
return new SSEClientTransport(url, { requestInit });
|
||||
} catch (sseError) {
|
||||
const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
|
||||
const sseMsg = sseError instanceof Error ? sseError.message : String(sseError);
|
||||
throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a single MCP server.
|
||||
* Returns connection object with client, transport, and discovered tools.
|
||||
*/
|
||||
static async connect(
|
||||
serverName: string,
|
||||
serverConfig: MCPServerConfig,
|
||||
clientInfo?: Implementation,
|
||||
capabilities?: ClientCapabilities
|
||||
): Promise<MCPConnection> {
|
||||
const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
|
||||
const effectiveCapabilities = capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
|
||||
|
||||
console.log(`[MCPService][${serverName}] Creating transport...`);
|
||||
const transport = this.createTransport(serverConfig);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: effectiveClientInfo.name,
|
||||
version: effectiveClientInfo.version ?? '1.0.0'
|
||||
},
|
||||
{ capabilities: effectiveCapabilities }
|
||||
);
|
||||
|
||||
console.log(`[MCPService][${serverName}] Connecting to server...`);
|
||||
await client.connect(transport);
|
||||
|
||||
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
|
||||
const tools = await this.listTools({ client, transport, tools: [], serverName });
|
||||
|
||||
console.log(`[MCPService][${serverName}] Initialization complete with ${tools.length} tools`);
|
||||
|
||||
return {
|
||||
client,
|
||||
transport,
|
||||
tools,
|
||||
serverName
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from a server.
|
||||
*/
|
||||
static async disconnect(connection: MCPConnection): Promise<void> {
|
||||
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
|
||||
try {
|
||||
await connection.client.close();
|
||||
} catch (error) {
|
||||
console.warn(`[MCPService][${connection.serverName}] Error during disconnect:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List tools from a connection.
|
||||
*/
|
||||
static async listTools(connection: MCPConnection): Promise<Tool[]> {
|
||||
try {
|
||||
const result = await connection.client.listTools();
|
||||
return result.tools ?? [];
|
||||
} catch (error) {
|
||||
console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool call on a connection.
|
||||
*/
|
||||
static async callTool(
|
||||
connection: MCPConnection,
|
||||
params: ToolCallParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolExecutionResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await connection.client.callTool(
|
||||
{ name: params.name, arguments: params.arguments },
|
||||
undefined,
|
||||
{ signal }
|
||||
);
|
||||
|
||||
return {
|
||||
content: this.formatToolResult(result as ToolCallResult),
|
||||
isError: (result as ToolCallResult).isError ?? false
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new MCPError(`Tool execution failed: ${message}`, -32603);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result content to string.
|
||||
*/
|
||||
private static formatToolResult(result: ToolCallResult): string {
|
||||
const content = result.content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
|
||||
return content
|
||||
.map((item) => this.formatSingleContent(item))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private static formatSingleContent(content: ToolResultContentItem): string {
|
||||
if (content.type === 'text' && content.text) {
|
||||
return content.text;
|
||||
}
|
||||
if (content.type === 'image' && content.data) {
|
||||
return `data:${content.mimeType ?? 'image/png'};base64,${content.data}`;
|
||||
}
|
||||
if (content.type === 'resource' && content.resource) {
|
||||
const resource = content.resource;
|
||||
if (resource.text) return resource.text;
|
||||
if (resource.blob) return resource.blob;
|
||||
return JSON.stringify(resource);
|
||||
}
|
||||
if (content.data && content.mimeType) {
|
||||
return `data:${content.mimeType};base64,${content.data}`;
|
||||
}
|
||||
return JSON.stringify(content);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,9 +18,13 @@ import { getJsonHeaders } from '$lib/utils';
|
|||
* - modelsStore: Primary consumer for model state management
|
||||
*/
|
||||
export class ModelsService {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Listing
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Listing
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch list of models from OpenAI-compatible endpoint
|
||||
|
|
@ -54,9 +58,13 @@ export class ModelsService {
|
|||
return response.json() as Promise<ApiRouterModelsListResponse>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Load/Unload
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Load/Unload
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load a model (ROUTER mode)
|
||||
|
|
@ -104,9 +112,13 @@ export class ModelsService {
|
|||
return response.json() as Promise<ApiRouterModelsUnloadResponse>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Status
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a model is loaded based on its metadata
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { ParameterSyncService } from './parameter-sync';
|
||||
import { ParameterSyncService } from './parameter-sync.service';
|
||||
|
||||
describe('ParameterSyncService', () => {
|
||||
describe('roundFloatingPoint', () => {
|
||||
|
|
@ -100,9 +100,13 @@ export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
|
|||
];
|
||||
|
||||
export class ParameterSyncService {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Extraction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Extraction
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Round floating-point numbers to avoid JavaScript precision issues
|
||||
|
|
@ -153,9 +157,13 @@ export class ParameterSyncService {
|
|||
return extracted;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Merging
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Merging
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Merge server defaults with current user settings
|
||||
|
|
@ -178,9 +186,13 @@ export class ParameterSyncService {
|
|||
return merged;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Info
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Info
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get parameter information including source and values
|
||||
|
|
@ -238,9 +250,13 @@ export class ParameterSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Diff
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Diff
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a diff between current settings and server defaults
|
||||
|
|
@ -15,9 +15,13 @@ import { getAuthHeaders } from '$lib/utils';
|
|||
* - serverStore: Primary consumer for server state management
|
||||
*/
|
||||
export class PropsService {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fetching
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Fetching
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches server properties from the /props endpoint
|
||||
|
|
@ -1,100 +1,73 @@
|
|||
/**
|
||||
* agenticStore - Orchestration of the agentic loop with MCP tools
|
||||
* agenticStore - Reactive State Store for Agentic Loop
|
||||
*
|
||||
* This store is responsible for:
|
||||
* - Managing the agentic loop lifecycle
|
||||
* - Coordinating between LLM and MCP tool execution
|
||||
* - Tracking session state (messages, turns, tool calls)
|
||||
* - Emitting streaming content and tool results
|
||||
* This store contains ONLY reactive state ($state).
|
||||
* All business logic is delegated to AgenticClient.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **mcpStore**: Provides MCP host manager for tool execution
|
||||
* - **chatStore**: Triggers agentic flow and receives streaming updates
|
||||
* - **OpenAISseClient**: LLM communication for streaming responses
|
||||
* - **settingsStore**: Provides agentic configuration (maxTurns, etc.)
|
||||
* - **AgenticClient**: Business logic facade (loop orchestration, tool execution)
|
||||
* - **MCPClient**: Tool execution via MCP servers
|
||||
* - **agenticStore** (this): Reactive state for UI components
|
||||
*
|
||||
* **Key Features:**
|
||||
* - Stateful session management (unlike stateless ChatService)
|
||||
* - Multi-turn tool call orchestration
|
||||
* - Automatic routing of tool calls to appropriate MCP servers
|
||||
* - Raw LLM output streaming (UI formatting is separate concern)
|
||||
* **Responsibilities:**
|
||||
* - Hold reactive state for UI binding (isRunning, currentTurn, etc.)
|
||||
* - Provide getters for computed values
|
||||
* - Expose setters for AgenticClient to update state
|
||||
* - Forward method calls to AgenticClient
|
||||
*
|
||||
* @see AgenticClient in clients/agentic/ for business logic
|
||||
* @see MCPClient in clients/mcp/ for tool execution
|
||||
*/
|
||||
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { OpenAISseClient, type OpenAISseTurnResult } from '$lib/clients/openai-sse';
|
||||
import type {
|
||||
AgenticMessage,
|
||||
AgenticChatCompletionRequest,
|
||||
AgenticToolCallList
|
||||
} from '$lib/types/agentic';
|
||||
import { toAgenticMessages } from '$lib/utils';
|
||||
import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api';
|
||||
import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat';
|
||||
import type { MCPToolCall } from '$lib/types/mcp';
|
||||
import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '$lib/types/database';
|
||||
import { getAgenticConfig } from '$lib/config/agentic';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { getAuthHeaders } from '$lib/utils';
|
||||
import { ChatService } from '$lib/services';
|
||||
import { browser } from '$app/environment';
|
||||
import type { AgenticFlowParams, AgenticFlowResult } from '$lib/clients';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AgenticFlowCallbacks {
|
||||
onChunk?: (chunk: string) => void;
|
||||
onReasoningChunk?: (chunk: string) => void;
|
||||
onToolCallChunk?: (serializedToolCalls: string) => void;
|
||||
onModel?: (model: string) => void;
|
||||
onComplete?: (
|
||||
content: string,
|
||||
reasoningContent?: string,
|
||||
timings?: ChatMessageTimings,
|
||||
toolCalls?: string
|
||||
) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
|
||||
}
|
||||
|
||||
export interface AgenticFlowOptions {
|
||||
stream?: boolean;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgenticFlowParams {
|
||||
messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[];
|
||||
options?: AgenticFlowOptions;
|
||||
callbacks: AgenticFlowCallbacks;
|
||||
signal?: AbortSignal;
|
||||
/** Per-chat MCP server overrides */
|
||||
perChatOverrides?: McpServerOverride[];
|
||||
}
|
||||
|
||||
export interface AgenticFlowResult {
|
||||
handled: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Agentic Store
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export type {
|
||||
AgenticFlowCallbacks,
|
||||
AgenticFlowOptions,
|
||||
AgenticFlowParams,
|
||||
AgenticFlowResult
|
||||
} from '$lib/clients';
|
||||
|
||||
class AgenticStore {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private _isRunning = $state(false);
|
||||
private _currentTurn = $state(0);
|
||||
private _totalToolCalls = $state(0);
|
||||
private _lastError = $state<Error | null>(null);
|
||||
private _streamingToolCall = $state<{ name: string; arguments: string } | null>(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Getters
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/** Reference to the client (lazy loaded to avoid circular dependency) */
|
||||
private _client: typeof import('$lib/clients/agentic.client').agenticClient | null = null;
|
||||
|
||||
private get client() {
|
||||
return this._client;
|
||||
}
|
||||
|
||||
/** Check if store is ready (client initialized) */
|
||||
get isReady(): boolean {
|
||||
return this._client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store by wiring up to the client.
|
||||
* Must be called once after app startup.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (!browser) return;
|
||||
if (this._client) return; // Already initialized
|
||||
|
||||
const { agenticClient } = await import('$lib/clients/agentic.client');
|
||||
this._client = agenticClient;
|
||||
|
||||
agenticClient.setStoreCallbacks({
|
||||
setRunning: (running) => (this._isRunning = running),
|
||||
setCurrentTurn: (turn) => (this._currentTurn = turn),
|
||||
setTotalToolCalls: (count) => (this._totalToolCalls = count),
|
||||
setLastError: (error) => (this._lastError = error),
|
||||
setStreamingToolCall: (tc) => (this._streamingToolCall = tc),
|
||||
clearStreamingToolCall: () => (this._streamingToolCall = null)
|
||||
});
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
|
|
@ -112,375 +85,37 @@ class AgenticStore {
|
|||
return this._lastError;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main Agentic Flow
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
get streamingToolCall(): { name: string; arguments: string } | null {
|
||||
return this._streamingToolCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the agentic orchestration loop with MCP tools.
|
||||
*
|
||||
* This is the main entry point called by chatStore when agentic mode is enabled.
|
||||
* It coordinates:
|
||||
* 1. Initial LLM request with available tools
|
||||
* 2. Tool call detection and execution via MCP
|
||||
* 3. Multi-turn loop until completion or turn limit
|
||||
*
|
||||
* @returns AgenticFlowResult indicating if the flow handled the request
|
||||
* Delegates to AgenticClient.
|
||||
*/
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
const { messages, options = {}, callbacks, signal, perChatOverrides } = params;
|
||||
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } =
|
||||
callbacks;
|
||||
|
||||
// Get agentic configuration (considering per-chat MCP overrides)
|
||||
const agenticConfig = getAgenticConfig(config(), perChatOverrides);
|
||||
if (!agenticConfig.enabled) {
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
// Ensure MCP is initialized with per-chat overrides
|
||||
const hostManager = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
if (!hostManager) {
|
||||
console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
const tools = mcpStore.getToolDefinitions();
|
||||
if (tools.length === 0) {
|
||||
console.log('[AgenticStore] No tools available, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`);
|
||||
|
||||
// Normalize messages to API format
|
||||
const normalizedMessages: ApiChatMessageData[] = messages
|
||||
.map((msg) => {
|
||||
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
|
||||
// DatabaseMessage - use ChatService to convert
|
||||
return ChatService.convertDbMessageToApiChatMessageData(
|
||||
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
||||
);
|
||||
}
|
||||
return msg as ApiChatMessageData;
|
||||
})
|
||||
.filter((msg) => {
|
||||
// Filter out empty system messages
|
||||
if (msg.role === 'system') {
|
||||
const content = typeof msg.content === 'string' ? msg.content : '';
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Reset state
|
||||
this._isRunning = true;
|
||||
this._currentTurn = 0;
|
||||
this._totalToolCalls = 0;
|
||||
this._lastError = null;
|
||||
|
||||
try {
|
||||
await this.executeAgenticLoop({
|
||||
messages: normalizedMessages,
|
||||
options,
|
||||
tools,
|
||||
agenticConfig,
|
||||
callbacks: {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onModel,
|
||||
onComplete,
|
||||
onError,
|
||||
onTimings
|
||||
},
|
||||
signal
|
||||
});
|
||||
return { handled: true };
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
this._lastError = normalizedError;
|
||||
onError?.(normalizedError);
|
||||
return { handled: true, error: normalizedError };
|
||||
} finally {
|
||||
this._isRunning = false;
|
||||
// Lazy Disconnect: Close MCP connections after agentic flow completes
|
||||
// This prevents continuous keepalive/heartbeat polling when tools are not in use
|
||||
await mcpStore.shutdown().catch((err) => {
|
||||
console.warn('[AgenticStore] Failed to shutdown MCP after flow:', err);
|
||||
});
|
||||
|
||||
console.log('[AgenticStore] MCP connections closed (lazy disconnect)');
|
||||
if (!this.client) {
|
||||
throw new Error('AgenticStore not initialized. Call init() first.');
|
||||
}
|
||||
return this.client.runAgenticFlow(params);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Private: Agentic Loop Implementation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async executeAgenticLoop(params: {
|
||||
messages: ApiChatMessageData[];
|
||||
options: AgenticFlowOptions;
|
||||
tools: ReturnType<typeof mcpStore.getToolDefinitions>;
|
||||
agenticConfig: ReturnType<typeof getAgenticConfig>;
|
||||
callbacks: AgenticFlowCallbacks;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { messages, options, tools, agenticConfig, callbacks, signal } = params;
|
||||
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onTimings } =
|
||||
callbacks;
|
||||
|
||||
// Set up LLM client
|
||||
const llmClient = new OpenAISseClient({
|
||||
url: './v1/chat/completions',
|
||||
buildHeaders: () => getAuthHeaders()
|
||||
});
|
||||
|
||||
// Prepare session state
|
||||
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
|
||||
const allToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let capturedTimings: ChatMessageTimings | undefined;
|
||||
|
||||
// Build base request from options (messages change per turn)
|
||||
const requestBase: AgenticChatCompletionRequest = {
|
||||
...options,
|
||||
stream: true,
|
||||
messages: []
|
||||
};
|
||||
|
||||
const maxTurns = agenticConfig.maxTurns;
|
||||
const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
|
||||
|
||||
// Run agentic loop
|
||||
for (let turn = 0; turn < maxTurns; turn++) {
|
||||
this._currentTurn = turn + 1;
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.('', undefined, capturedTimings, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build LLM request for this turn
|
||||
const llmRequest: AgenticChatCompletionRequest = {
|
||||
...requestBase,
|
||||
messages: sessionMessages,
|
||||
tools: tools.length > 0 ? tools : undefined
|
||||
};
|
||||
|
||||
// Filter reasoning content after first turn if configured
|
||||
const shouldFilterReasoning = agenticConfig.filterReasoningAfterFirstTurn && turn > 0;
|
||||
|
||||
// Stream from LLM
|
||||
let turnResult: OpenAISseTurnResult;
|
||||
try {
|
||||
turnResult = await llmClient.stream(
|
||||
llmRequest,
|
||||
{
|
||||
onChunk,
|
||||
onReasoningChunk: shouldFilterReasoning ? undefined : onReasoningChunk,
|
||||
onModel,
|
||||
onFirstValidChunk: undefined,
|
||||
onProcessingUpdate: (timings, progress) => {
|
||||
onTimings?.(timings, progress);
|
||||
if (timings) capturedTimings = timings;
|
||||
}
|
||||
},
|
||||
signal
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.('', undefined, capturedTimings, undefined);
|
||||
return;
|
||||
}
|
||||
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
|
||||
onChunk?.(`\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`);
|
||||
onComplete?.('', undefined, capturedTimings, undefined);
|
||||
throw normalizedError;
|
||||
}
|
||||
|
||||
// Check if we should stop (no tool calls or finish reason isn't tool_calls)
|
||||
if (
|
||||
turnResult.toolCalls.length === 0 ||
|
||||
(turnResult.finishReason && turnResult.finishReason !== 'tool_calls')
|
||||
) {
|
||||
onComplete?.('', undefined, capturedTimings, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize and validate tool calls
|
||||
const normalizedCalls = this.normalizeToolCalls(turnResult.toolCalls);
|
||||
if (normalizedCalls.length === 0) {
|
||||
onComplete?.('', undefined, capturedTimings, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Accumulate tool calls
|
||||
for (const call of normalizedCalls) {
|
||||
allToolCalls.push({
|
||||
id: call.id,
|
||||
type: call.type,
|
||||
function: call.function ? { ...call.function } : undefined
|
||||
});
|
||||
}
|
||||
this._totalToolCalls = allToolCalls.length;
|
||||
onToolCallChunk?.(JSON.stringify(allToolCalls));
|
||||
|
||||
// Add assistant message with tool calls to session
|
||||
sessionMessages.push({
|
||||
role: 'assistant',
|
||||
content: turnResult.content || undefined,
|
||||
tool_calls: normalizedCalls
|
||||
});
|
||||
|
||||
// Execute each tool call via MCP
|
||||
for (const toolCall of normalizedCalls) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.('', undefined, capturedTimings, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit tool call start (shows "pending" state in UI)
|
||||
this.emitToolCallStart(toolCall, onChunk);
|
||||
|
||||
const mcpCall: MCPToolCall = {
|
||||
id: toolCall.id,
|
||||
function: {
|
||||
name: toolCall.function.name,
|
||||
arguments: toolCall.function.arguments
|
||||
}
|
||||
};
|
||||
|
||||
let result: string;
|
||||
try {
|
||||
const executionResult = await mcpStore.executeTool(mcpCall, signal);
|
||||
result = executionResult.content;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
onComplete?.('', undefined, capturedTimings, undefined);
|
||||
return;
|
||||
}
|
||||
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.('', undefined, capturedTimings, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit tool result and end marker
|
||||
this.emitToolCallResult(result, maxToolPreviewLines, onChunk);
|
||||
|
||||
// Add tool result to session (sanitize base64 images for context)
|
||||
const contextValue = this.isBase64Image(result) ? '[Image displayed to user]' : result;
|
||||
sessionMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: contextValue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Turn limit reached
|
||||
onChunk?.('\n\n```\nTurn limit reached\n```\n');
|
||||
onComplete?.('', undefined, capturedTimings, undefined);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Private: Helper Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize tool calls from LLM response
|
||||
*/
|
||||
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
|
||||
if (!toolCalls) return [];
|
||||
return toolCalls.map((call, index) => ({
|
||||
id: call?.id ?? `tool_${index}`,
|
||||
type: (call?.type as 'function') ?? 'function',
|
||||
function: {
|
||||
name: call?.function?.name ?? '',
|
||||
arguments: call?.function?.arguments ?? ''
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit tool call start marker (shows "pending" state in UI).
|
||||
*/
|
||||
private emitToolCallStart(
|
||||
toolCall: AgenticToolCallList[number],
|
||||
emit?: (chunk: string) => void
|
||||
): void {
|
||||
if (!emit) return;
|
||||
|
||||
const toolName = toolCall.function.name;
|
||||
const toolArgs = toolCall.function.arguments;
|
||||
// Base64 encode args to avoid conflicts with markdown/HTML parsing
|
||||
const toolArgsBase64 = btoa(unescape(encodeURIComponent(toolArgs)));
|
||||
|
||||
let output = `\n\n<<<AGENTIC_TOOL_CALL_START>>>`;
|
||||
output += `\n<<<TOOL_NAME:${toolName}>>>`;
|
||||
output += `\n<<<TOOL_ARGS_BASE64:${toolArgsBase64}>>>`;
|
||||
emit(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit tool call result and end marker.
|
||||
*/
|
||||
private emitToolCallResult(
|
||||
result: string,
|
||||
maxLines: number,
|
||||
emit?: (chunk: string) => void
|
||||
): void {
|
||||
if (!emit) return;
|
||||
|
||||
let output = '';
|
||||
if (this.isBase64Image(result)) {
|
||||
output += `\n})`;
|
||||
} else {
|
||||
// Don't wrap in code fences - result may already be markdown with its own code blocks
|
||||
const lines = result.split('\n');
|
||||
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
|
||||
output += `\n${trimmedLines.join('\n')}`;
|
||||
}
|
||||
|
||||
output += `\n<<<AGENTIC_TOOL_CALL_END>>>\n`;
|
||||
emit(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is a base64 image
|
||||
*/
|
||||
private isBase64Image(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith('data:image/')) return false;
|
||||
|
||||
const match = trimmed.match(/^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+=*)$/);
|
||||
if (!match) return false;
|
||||
|
||||
const base64Payload = match[2];
|
||||
return base64Payload.length > 0 && base64Payload.length % 4 === 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError(): void {
|
||||
this._lastError = null;
|
||||
if (!this.client) return;
|
||||
this.client.clearError();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Singleton Instance & Exports
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const agenticStore = new AgenticStore();
|
||||
|
||||
// Reactive exports for components
|
||||
// Auto-initialize in browser
|
||||
if (browser) {
|
||||
agenticStore.init();
|
||||
}
|
||||
|
||||
export function agenticIsRunning() {
|
||||
return agenticStore.isRunning;
|
||||
}
|
||||
|
|
@ -496,3 +131,7 @@ export function agenticTotalToolCalls() {
|
|||
export function agenticLastError() {
|
||||
return agenticStore.lastError;
|
||||
}
|
||||
|
||||
export function agenticStreamingToolCall() {
|
||||
return agenticStore.streamingToolCall;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,56 +1,29 @@
|
|||
/**
|
||||
* conversationsStore - Reactive State Store for Conversations
|
||||
*
|
||||
* This store contains ONLY reactive state ($state, $derived).
|
||||
* All business logic is delegated to ConversationsClient.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ConversationsClient**: Business logic facade (CRUD, navigation, import/export)
|
||||
* - **DatabaseService**: Stateless IndexedDB layer
|
||||
* - **conversationsStore** (this): Reactive state for UI components
|
||||
*
|
||||
* **Responsibilities:**
|
||||
* - Hold reactive state for UI binding
|
||||
* - Provide getters for computed values
|
||||
* - Expose setters for ConversationsClient to update state
|
||||
* - Forward method calls to ConversationsClient
|
||||
*
|
||||
* @see ConversationsClient in clients/ for business logic
|
||||
* @see DatabaseService in services/database.ts for IndexedDB operations
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { DatabaseService } from '$lib/services/database';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
|
||||
/**
|
||||
* conversationsStore - Persistent conversation data and lifecycle management
|
||||
*
|
||||
* **Terminology - Chat vs Conversation:**
|
||||
* - **Chat**: The active interaction space with the Chat Completions API. Represents the
|
||||
* real-time streaming session, loading states, and UI visualization of AI communication.
|
||||
* Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions.
|
||||
* - **Conversation**: The persistent database entity storing all messages and metadata.
|
||||
* A "conversation" survives across sessions, page reloads, and browser restarts.
|
||||
* It contains the complete message history, branching structure, and conversation metadata.
|
||||
*
|
||||
* This store manages all conversation-level data and operations including creation, loading,
|
||||
* deletion, and navigation. It maintains the list of conversations and the currently active
|
||||
* conversation with its message history, providing reactive state for UI components.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **conversationsStore** (this class): Persistent conversation data management
|
||||
* - Manages conversation list and active conversation state
|
||||
* - Handles conversation CRUD operations via DatabaseService
|
||||
* - Maintains active message array for current conversation
|
||||
* - Coordinates branching navigation (currNode tracking)
|
||||
*
|
||||
* - **chatStore**: Uses conversation data as context for active AI streaming
|
||||
* - **DatabaseService**: Low-level IndexedDB storage for conversations and messages
|
||||
*
|
||||
* **Key Features:**
|
||||
* - **Conversation Lifecycle**: Create, load, update, delete conversations
|
||||
* - **Message Management**: Active message array with branching support
|
||||
* - **Import/Export**: JSON-based conversation backup and restore
|
||||
* - **Branch Navigation**: Navigate between message tree branches
|
||||
* - **Title Management**: Auto-update titles with confirmation dialogs
|
||||
* - **Reactive State**: Svelte 5 runes for automatic UI updates
|
||||
*
|
||||
* **State Properties:**
|
||||
* - `conversations`: All conversations sorted by last modified
|
||||
* - `activeConversation`: Currently viewed conversation
|
||||
* - `activeMessages`: Messages in current conversation path
|
||||
* - `isInitialized`: Store initialization status
|
||||
*/
|
||||
class ConversationsStore {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List of all conversations */
|
||||
conversations = $state<DatabaseConversation[]>([]);
|
||||
|
||||
|
|
@ -69,22 +42,57 @@ class ConversationsStore {
|
|||
/** Callback for title update confirmation dialog */
|
||||
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Modalities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Modalities used in the active conversation.
|
||||
* Computed from attachments in activeMessages.
|
||||
* Used to filter available models - models must support all used modalities.
|
||||
*/
|
||||
usedModalities: ModelModalities = $derived.by(() => {
|
||||
return this.calculateModalitiesFromMessages(this.activeMessages);
|
||||
});
|
||||
|
||||
/** Reference to the client (lazy loaded to avoid circular dependency) */
|
||||
private _client: typeof import('$lib/clients/conversations.client').conversationsClient | null =
|
||||
null;
|
||||
|
||||
private get client() {
|
||||
return this._client;
|
||||
}
|
||||
|
||||
/** Check if store is ready (client initialized) */
|
||||
get isReady(): boolean {
|
||||
return this._client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store by wiring up to the client.
|
||||
* Must be called once after app startup.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (!browser) return;
|
||||
if (this._client) return;
|
||||
|
||||
const { conversationsClient } = await import('$lib/clients/conversations.client');
|
||||
this._client = conversationsClient;
|
||||
|
||||
conversationsClient.setStoreCallbacks({
|
||||
getConversations: () => this.conversations,
|
||||
setConversations: (conversations) => (this.conversations = conversations),
|
||||
getActiveConversation: () => this.activeConversation,
|
||||
setActiveConversation: (conversation) => (this.activeConversation = conversation),
|
||||
getActiveMessages: () => this.activeMessages,
|
||||
setActiveMessages: (messages) => (this.activeMessages = messages),
|
||||
updateActiveMessages: (updater) => (this.activeMessages = updater(this.activeMessages)),
|
||||
setInitialized: (initialized) => (this.isInitialized = initialized),
|
||||
getPendingMcpServerOverrides: () => this.pendingMcpServerOverrides,
|
||||
setPendingMcpServerOverrides: (overrides) => (this.pendingMcpServerOverrides = overrides),
|
||||
getTitleUpdateConfirmationCallback: () => this.titleUpdateConfirmationCallback
|
||||
});
|
||||
|
||||
await conversationsClient.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate modalities from a list of messages.
|
||||
* Helper method used by both usedModalities and getModalitiesUpToMessage.
|
||||
*/
|
||||
private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
|
||||
const modalities: ModelModalities = { vision: false, audio: false };
|
||||
|
|
@ -119,7 +127,6 @@ class ConversationsStore {
|
|||
|
||||
/**
|
||||
* Get modalities used in messages BEFORE the specified message.
|
||||
* Used for regeneration - only consider context that was available when generating this message.
|
||||
*/
|
||||
getModalitiesUpToMessage(messageId: string): ModelModalities {
|
||||
const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
|
||||
|
|
@ -132,596 +139,8 @@ class ConversationsStore {
|
|||
return this.calculateModalitiesFromMessages(messagesBefore);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initializes the conversations store by loading conversations from the database
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.loadConversations();
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize conversations store:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all conversations from the database
|
||||
*/
|
||||
async loadConversations(): Promise<void> {
|
||||
this.conversations = await DatabaseService.getAllConversations();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Conversation CRUD
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a new conversation and navigates to it
|
||||
* @param name - Optional name for the conversation
|
||||
* @returns The ID of the created conversation
|
||||
*/
|
||||
async createConversation(name?: string): Promise<string> {
|
||||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||
const conversation = await DatabaseService.createConversation(conversationName);
|
||||
|
||||
// Apply any pending MCP server overrides to the new conversation
|
||||
if (this.pendingMcpServerOverrides.length > 0) {
|
||||
// Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
|
||||
const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}));
|
||||
conversation.mcpServerOverrides = plainOverrides;
|
||||
await DatabaseService.updateConversation(conversation.id, {
|
||||
mcpServerOverrides: plainOverrides
|
||||
});
|
||||
this.pendingMcpServerOverrides = []; // Clear pending overrides
|
||||
}
|
||||
|
||||
this.conversations.unshift(conversation);
|
||||
this.activeConversation = conversation;
|
||||
this.activeMessages = [];
|
||||
|
||||
await goto(`#/chat/${conversation.id}`);
|
||||
|
||||
return conversation.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a specific conversation and its messages
|
||||
* @param convId - The conversation ID to load
|
||||
* @returns True if conversation was loaded successfully
|
||||
*/
|
||||
async loadConversation(convId: string): Promise<boolean> {
|
||||
try {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
|
||||
if (!conversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear pending overrides when switching to an existing conversation
|
||||
this.pendingMcpServerOverrides = [];
|
||||
this.activeConversation = conversation;
|
||||
|
||||
if (conversation.currNode) {
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
this.activeMessages = filterByLeafNodeId(
|
||||
allMessages,
|
||||
conversation.currNode,
|
||||
false
|
||||
) as DatabaseMessage[];
|
||||
} else {
|
||||
this.activeMessages = await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active conversation and messages
|
||||
* Used when navigating away from chat or starting fresh
|
||||
*/
|
||||
clearActiveConversation(): void {
|
||||
this.activeConversation = null;
|
||||
this.activeMessages = [];
|
||||
// Active processing conversation is now managed by chatStore
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Message Management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refreshes active messages based on currNode after branch navigation
|
||||
*/
|
||||
async refreshActiveMessages(): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
this.activeMessages = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const leafNodeId =
|
||||
this.activeConversation.currNode ||
|
||||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
|
||||
|
||||
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
||||
|
||||
this.activeMessages.length = 0;
|
||||
this.activeMessages.push(...currentPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the name of a conversation
|
||||
* @param convId - The conversation ID to update
|
||||
* @param name - The new name for the conversation
|
||||
*/
|
||||
async updateConversationName(convId: string, name: string): Promise<void> {
|
||||
try {
|
||||
await DatabaseService.updateConversation(convId, { name });
|
||||
|
||||
const convIndex = this.conversations.findIndex((c) => c.id === convId);
|
||||
|
||||
if (convIndex !== -1) {
|
||||
this.conversations[convIndex].name = name;
|
||||
}
|
||||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
this.activeConversation.name = name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation name:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation title with optional confirmation dialog based on settings
|
||||
* @param convId - The conversation ID to update
|
||||
* @param newTitle - The new title content
|
||||
* @param onConfirmationNeeded - Callback when user confirmation is needed
|
||||
* @returns True if title was updated, false if cancelled
|
||||
*/
|
||||
async updateConversationTitleWithConfirmation(
|
||||
convId: string,
|
||||
newTitle: string,
|
||||
onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const currentConfig = config();
|
||||
|
||||
if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return false;
|
||||
|
||||
const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
|
||||
if (!shouldUpdate) return false;
|
||||
}
|
||||
|
||||
await this.updateConversationName(convId, newTitle);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation title with confirmation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Navigation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Updates the current node of the active conversation
|
||||
* @param nodeId - The new current node ID
|
||||
*/
|
||||
async updateCurrentNode(nodeId: string): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
|
||||
this.activeConversation.currNode = nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation lastModified timestamp and moves it to top of list
|
||||
*/
|
||||
updateConversationTimestamp(): void {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||
|
||||
if (chatIndex !== -1) {
|
||||
this.conversations[chatIndex].lastModified = Date.now();
|
||||
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
|
||||
this.conversations.unshift(updatedConv);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP Server Per-Chat Overrides
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gets MCP server override for a specific server in the active conversation.
|
||||
* Falls back to pending overrides if no active conversation exists.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns The override if set, undefined if using global setting
|
||||
*/
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
if (this.activeConversation) {
|
||||
return this.activeConversation.mcpServerOverrides?.find(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
}
|
||||
// Fall back to pending overrides if no active conversation
|
||||
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an MCP server is enabled for the active conversation.
|
||||
* Per-chat override takes precedence over global setting.
|
||||
* @param serverId - The server ID to check
|
||||
* @param globalEnabled - The global enabled state from settings
|
||||
* @returns True if server is enabled for this conversation
|
||||
*/
|
||||
isMcpServerEnabledForChat(serverId: string, globalEnabled: boolean): boolean {
|
||||
const override = this.getMcpServerOverride(serverId);
|
||||
return override !== undefined ? override.enabled : globalEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes MCP server override for the active conversation.
|
||||
* If no conversation exists, stores as pending override (applied when conversation is created).
|
||||
* @param serverId - The server ID to override
|
||||
* @param enabled - The enabled state, or undefined to remove override (use global)
|
||||
*/
|
||||
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
|
||||
// If no active conversation, store as pending override
|
||||
if (!this.activeConversation) {
|
||||
this.setPendingMcpServerOverride(serverId, enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
|
||||
const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map(
|
||||
(o: McpServerOverride) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
})
|
||||
);
|
||||
let newOverrides: McpServerOverride[];
|
||||
|
||||
if (enabled === undefined) {
|
||||
// Remove override - use global setting
|
||||
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
|
||||
} else {
|
||||
// Set or update override
|
||||
const existingIndex = currentOverrides.findIndex(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
newOverrides = [...currentOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
} else {
|
||||
newOverrides = [...currentOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
|
||||
// Update in database (plain objects, not proxies)
|
||||
await DatabaseService.updateConversation(this.activeConversation.id, {
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
});
|
||||
|
||||
// Update local state
|
||||
this.activeConversation.mcpServerOverrides = newOverrides.length > 0 ? newOverrides : undefined;
|
||||
|
||||
// Also update in conversations list
|
||||
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||
if (convIndex !== -1) {
|
||||
this.conversations[convIndex].mcpServerOverrides =
|
||||
newOverrides.length > 0 ? newOverrides : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles MCP server enabled state for the active conversation.
|
||||
* Creates a per-chat override that differs from the global setting.
|
||||
* @param serverId - The server ID to toggle
|
||||
* @param globalEnabled - The global enabled state from settings
|
||||
*/
|
||||
async toggleMcpServerForChat(serverId: string, globalEnabled: boolean): Promise<void> {
|
||||
const currentEnabled = this.isMcpServerEnabledForChat(serverId, globalEnabled);
|
||||
await this.setMcpServerOverride(serverId, !currentEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets MCP server to use global setting (removes per-chat override).
|
||||
* @param serverId - The server ID to reset
|
||||
*/
|
||||
async resetMcpServerToGlobal(serverId: string): Promise<void> {
|
||||
await this.setMcpServerOverride(serverId, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes a pending MCP server override (for new conversations).
|
||||
* @param serverId - The server ID to override
|
||||
* @param enabled - The enabled state, or undefined to remove override
|
||||
*/
|
||||
private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
|
||||
if (enabled === undefined) {
|
||||
// Remove pending override
|
||||
this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter(
|
||||
(o) => o.serverId !== serverId
|
||||
);
|
||||
} else {
|
||||
// Set or update pending override
|
||||
const existingIndex = this.pendingMcpServerOverrides.findIndex(
|
||||
(o) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
this.pendingMcpServerOverrides[existingIndex] = { serverId, enabled };
|
||||
} else {
|
||||
this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a pending MCP server override.
|
||||
* @param serverId - The server ID to check
|
||||
*/
|
||||
private getPendingMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all pending MCP server overrides.
|
||||
*/
|
||||
clearPendingMcpServerOverrides(): void {
|
||||
this.pendingMcpServerOverrides = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to a specific sibling branch by updating currNode and refreshing messages
|
||||
* @param siblingId - The sibling message ID to navigate to
|
||||
*/
|
||||
async navigateToSibling(siblingId: string): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
||||
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
||||
const currentFirstUserMessage = this.activeMessages.find(
|
||||
(m) => m.role === 'user' && m.parent === rootMessage?.id
|
||||
);
|
||||
|
||||
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
|
||||
this.activeConversation.currNode = currentLeafNodeId;
|
||||
await this.refreshActiveMessages();
|
||||
|
||||
// Only show title dialog if we're navigating between different first user message siblings
|
||||
if (rootMessage && this.activeMessages.length > 0) {
|
||||
const newFirstUserMessage = this.activeMessages.find(
|
||||
(m) => m.role === 'user' && m.parent === rootMessage.id
|
||||
);
|
||||
|
||||
if (
|
||||
newFirstUserMessage &&
|
||||
newFirstUserMessage.content.trim() &&
|
||||
(!currentFirstUserMessage ||
|
||||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
|
||||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
|
||||
) {
|
||||
await this.updateConversationTitleWithConfirmation(
|
||||
this.activeConversation.id,
|
||||
newFirstUserMessage.content.trim(),
|
||||
this.titleUpdateConfirmationCallback
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a conversation and all its messages
|
||||
* @param convId - The conversation ID to delete
|
||||
*/
|
||||
async deleteConversation(convId: string): Promise<void> {
|
||||
try {
|
||||
await DatabaseService.deleteConversation(convId);
|
||||
|
||||
this.conversations = this.conversations.filter((c) => c.id !== convId);
|
||||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
this.clearActiveConversation();
|
||||
await goto(`?new_chat=true#/`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all conversations and their messages
|
||||
*/
|
||||
async deleteAll(): Promise<void> {
|
||||
try {
|
||||
const allConversations = await DatabaseService.getAllConversations();
|
||||
|
||||
for (const conv of allConversations) {
|
||||
await DatabaseService.deleteConversation(conv.id);
|
||||
}
|
||||
|
||||
this.clearActiveConversation();
|
||||
this.conversations = [];
|
||||
|
||||
toast.success('All conversations deleted');
|
||||
|
||||
await goto(`?new_chat=true#/`);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete all conversations:', error);
|
||||
toast.error('Failed to delete conversations');
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Import/Export
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Downloads a conversation as JSON file
|
||||
* @param convId - The conversation ID to download
|
||||
*/
|
||||
async downloadConversation(convId: string): Promise<void> {
|
||||
let conversation: DatabaseConversation | null;
|
||||
let messages: DatabaseMessage[];
|
||||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
conversation = this.activeConversation;
|
||||
messages = this.activeMessages;
|
||||
} else {
|
||||
conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return;
|
||||
messages = await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
this.triggerDownload({ conv: conversation, messages });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all conversations with their messages as a JSON file
|
||||
* @returns The list of exported conversations
|
||||
*/
|
||||
async exportAllConversations(): Promise<DatabaseConversation[]> {
|
||||
const allConversations = await DatabaseService.getAllConversations();
|
||||
|
||||
if (allConversations.length === 0) {
|
||||
throw new Error('No conversations to export');
|
||||
}
|
||||
|
||||
const allData = await Promise.all(
|
||||
allConversations.map(async (conv) => {
|
||||
const messages = await DatabaseService.getConversationMessages(conv.id);
|
||||
return { conv, messages };
|
||||
})
|
||||
);
|
||||
|
||||
const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`All conversations (${allConversations.length}) prepared for download`);
|
||||
|
||||
return allConversations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports conversations from a JSON file
|
||||
* Opens file picker and processes the selected file
|
||||
* @returns The list of imported conversations
|
||||
*/
|
||||
async importConversations(): Promise<DatabaseConversation[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement)?.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
reject(new Error('No file selected'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsedData = JSON.parse(text);
|
||||
let importedData: ExportedConversations;
|
||||
|
||||
if (Array.isArray(parsedData)) {
|
||||
importedData = parsedData;
|
||||
} else if (
|
||||
parsedData &&
|
||||
typeof parsedData === 'object' &&
|
||||
'conv' in parsedData &&
|
||||
'messages' in parsedData
|
||||
) {
|
||||
importedData = [parsedData];
|
||||
} else {
|
||||
throw new Error('Invalid file format');
|
||||
}
|
||||
|
||||
const result = await DatabaseService.importConversations(importedData);
|
||||
toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
|
||||
|
||||
await this.loadConversations();
|
||||
|
||||
const importedConversations = (
|
||||
Array.isArray(importedData) ? importedData : [importedData]
|
||||
).map((item) => item.conv);
|
||||
|
||||
resolve(importedConversations);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('Failed to import conversations:', err);
|
||||
toast.error('Import failed', { description: message });
|
||||
reject(new Error(`Import failed: ${message}`));
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all messages for a specific conversation
|
||||
* @param convId - The conversation ID
|
||||
* @returns Array of messages
|
||||
*/
|
||||
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||
return await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports conversations from provided data (without file picker)
|
||||
* @param data - Array of conversation data with messages
|
||||
* @returns Import result with counts
|
||||
*/
|
||||
async importConversationsData(
|
||||
data: ExportedConversations
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
const result = await DatabaseService.importConversations(data);
|
||||
await this.loadConversations();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a message to the active messages array
|
||||
* Used by chatStore when creating new messages
|
||||
* @param message - The message to add
|
||||
*/
|
||||
addMessageToActive(message: DatabaseMessage): void {
|
||||
this.activeMessages.push(message);
|
||||
|
|
@ -729,21 +148,15 @@ class ConversationsStore {
|
|||
|
||||
/**
|
||||
* Updates a message at a specific index in active messages
|
||||
* Creates a new object to trigger Svelte 5 reactivity
|
||||
* @param index - The index of the message to update
|
||||
* @param updates - Partial message data to update
|
||||
*/
|
||||
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
|
||||
if (index !== -1 && this.activeMessages[index]) {
|
||||
// Create new object to trigger Svelte 5 reactivity
|
||||
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of a message in active messages
|
||||
* @param messageId - The message ID to find
|
||||
* @returns The index of the message, or -1 if not found
|
||||
*/
|
||||
findMessageIndex(messageId: string): number {
|
||||
return this.activeMessages.findIndex((m) => m.id === messageId);
|
||||
|
|
@ -751,7 +164,6 @@ class ConversationsStore {
|
|||
|
||||
/**
|
||||
* Removes messages from active messages starting at an index
|
||||
* @param startIndex - The index to start removing from
|
||||
*/
|
||||
sliceActiveMessages(startIndex: number): void {
|
||||
this.activeMessages = this.activeMessages.slice(0, startIndex);
|
||||
|
|
@ -759,8 +171,6 @@ class ConversationsStore {
|
|||
|
||||
/**
|
||||
* Removes a message from active messages by index
|
||||
* @param index - The index to remove
|
||||
* @returns The removed message or undefined
|
||||
*/
|
||||
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
|
||||
if (index !== -1) {
|
||||
|
|
@ -769,56 +179,156 @@ class ConversationsStore {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers file download in browser
|
||||
* @param data - The data to download
|
||||
* @param filename - Optional filename for the download
|
||||
*/
|
||||
private triggerDownload(data: ExportedConversations, filename?: string): void {
|
||||
const conversation =
|
||||
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
|
||||
|
||||
if (!conversation) {
|
||||
console.error('Invalid data: missing conversation');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationName = conversation.name?.trim() || '';
|
||||
const truncatedSuffix = conversationName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/gi, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.substring(0, 20);
|
||||
const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFilename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sets the callback function for title update confirmations
|
||||
* @param callback - Function to call when confirmation is needed
|
||||
*/
|
||||
setTitleUpdateConfirmationCallback(
|
||||
callback: (currentTitle: string, newTitle: string) => Promise<boolean>
|
||||
): void {
|
||||
this.titleUpdateConfirmationCallback = callback;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.initialize();
|
||||
}
|
||||
|
||||
async loadConversations(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.loadConversations();
|
||||
}
|
||||
|
||||
async createConversation(name?: string): Promise<string> {
|
||||
if (!this.client) throw new Error('ConversationsStore not initialized');
|
||||
return this.client.createConversation(name);
|
||||
}
|
||||
|
||||
async loadConversation(convId: string): Promise<boolean> {
|
||||
if (!this.client) return false;
|
||||
return this.client.loadConversation(convId);
|
||||
}
|
||||
|
||||
clearActiveConversation(): void {
|
||||
if (!this.client) return;
|
||||
this.client.clearActiveConversation();
|
||||
}
|
||||
|
||||
async deleteConversation(convId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.deleteConversation(convId);
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.deleteAll();
|
||||
}
|
||||
|
||||
async refreshActiveMessages(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.refreshActiveMessages();
|
||||
}
|
||||
|
||||
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||
if (!this.client) return [];
|
||||
return this.client.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
async updateConversationName(convId: string, name: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.updateConversationName(convId, name);
|
||||
}
|
||||
|
||||
async updateConversationTitleWithConfirmation(
|
||||
convId: string,
|
||||
newTitle: string
|
||||
): Promise<boolean> {
|
||||
if (!this.client) return false;
|
||||
return this.client.updateConversationTitleWithConfirmation(convId, newTitle);
|
||||
}
|
||||
|
||||
updateConversationTimestamp(): void {
|
||||
if (!this.client) return;
|
||||
this.client.updateConversationTimestamp();
|
||||
}
|
||||
|
||||
async updateCurrentNode(nodeId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.updateCurrentNode(nodeId);
|
||||
}
|
||||
|
||||
async navigateToSibling(siblingId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.navigateToSibling(siblingId);
|
||||
}
|
||||
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
if (!this.client) {
|
||||
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
}
|
||||
return this.client.getMcpServerOverride(serverId);
|
||||
}
|
||||
|
||||
isMcpServerEnabledForChat(serverId: string, globalEnabled: boolean): boolean {
|
||||
if (!this.client) {
|
||||
const override = this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
return override !== undefined ? override.enabled : globalEnabled;
|
||||
}
|
||||
return this.client.isMcpServerEnabledForChat(serverId, globalEnabled);
|
||||
}
|
||||
|
||||
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.setMcpServerOverride(serverId, enabled);
|
||||
}
|
||||
|
||||
async toggleMcpServerForChat(serverId: string, globalEnabled: boolean): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.toggleMcpServerForChat(serverId, globalEnabled);
|
||||
}
|
||||
|
||||
async resetMcpServerToGlobal(serverId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.resetMcpServerToGlobal(serverId);
|
||||
}
|
||||
|
||||
clearPendingMcpServerOverrides(): void {
|
||||
if (!this.client) {
|
||||
this.pendingMcpServerOverrides = [];
|
||||
return;
|
||||
}
|
||||
this.client.clearPendingMcpServerOverrides();
|
||||
}
|
||||
|
||||
async downloadConversation(convId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
return this.client.downloadConversation(convId);
|
||||
}
|
||||
|
||||
async exportAllConversations(): Promise<DatabaseConversation[]> {
|
||||
if (!this.client) return [];
|
||||
return this.client.exportAllConversations();
|
||||
}
|
||||
|
||||
async importConversations(): Promise<DatabaseConversation[]> {
|
||||
if (!this.client) return [];
|
||||
return this.client.importConversations();
|
||||
}
|
||||
|
||||
async importConversationsData(
|
||||
data: ExportedConversations
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
if (!this.client) return { imported: 0, skipped: 0 };
|
||||
return this.client.importConversationsData(data);
|
||||
}
|
||||
}
|
||||
|
||||
export const conversationsStore = new ConversationsStore();
|
||||
|
||||
// Auto-initialize in browser
|
||||
if (browser) {
|
||||
conversationsStore.init();
|
||||
}
|
||||
|
||||
export const conversations = () => conversationsStore.conversations;
|
||||
export const activeConversation = () => conversationsStore.activeConversation;
|
||||
export const activeMessages = () => conversationsStore.activeMessages;
|
||||
|
|
|
|||
|
|
@ -1,67 +1,66 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { MCPHostManager } from '$lib/mcp/host-manager';
|
||||
import { MCPServerConnection } from '$lib/mcp/server-connection';
|
||||
import type { OpenAIToolDefinition, ServerStatus, ToolExecutionResult } from '$lib/types/mcp';
|
||||
import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/config/mcp';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import type { MCPToolCall } from '$lib/types/mcp';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
import { detectMcpTransportFromUrl } from '$lib/utils/mcp';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Health Check Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type HealthCheckState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'success'; tools: { name: string; description?: string }[] };
|
||||
|
||||
/**
|
||||
* mcpStore - Reactive store for MCP (Model Context Protocol) host management
|
||||
* mcpStore - Reactive State Store for MCP (Model Context Protocol)
|
||||
*
|
||||
* This store manages:
|
||||
* - MCPHostManager lifecycle (initialization, shutdown)
|
||||
* - Connection state tracking for multiple MCP servers
|
||||
* - Aggregated tools from all connected MCP servers
|
||||
* - Error handling for MCP operations
|
||||
* This store contains ONLY reactive state ($state, $derived).
|
||||
* All business logic is delegated to MCPClient.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **MCPHostManager**: Coordinates multiple MCPServerConnection instances
|
||||
* - **MCPServerConnection**: Single SDK Client wrapper per server
|
||||
* - **mcpStore** (this class): Reactive Svelte store for MCP state
|
||||
* - **agenticStore**: Uses mcpStore for tool execution in agentic loop
|
||||
* - **settingsStore**: Provides MCP server configuration
|
||||
* - **MCPClient**: Business logic facade (lifecycle, tool execution, health checks)
|
||||
* - **MCPService**: Stateless protocol layer (transport, connect, callTool)
|
||||
* - **mcpStore** (this): Reactive state for UI components
|
||||
*
|
||||
* **Key Features:**
|
||||
* - Reactive state with Svelte 5 runes ($state, $derived)
|
||||
* - Automatic reinitialization on config changes
|
||||
* - Aggregates tools from multiple servers
|
||||
* - Routes tool calls to appropriate server automatically
|
||||
* - Graceful error handling with fallback to standard chat
|
||||
* **Responsibilities:**
|
||||
* - Hold reactive state for UI binding
|
||||
* - Provide getters for computed values
|
||||
* - Expose setters for MCPClient to update state
|
||||
* - Forward method calls to MCPClient
|
||||
*
|
||||
* @see MCPClient in clients/mcp/ for business logic
|
||||
* @see MCPService in services/mcp.ts for protocol operations
|
||||
*/
|
||||
class MCPStore {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private _hostManager = $state<MCPHostManager | null>(null);
|
||||
import { browser } from '$app/environment';
|
||||
import { mcpClient, type HealthCheckState, type HealthCheckParams } from '$lib/clients';
|
||||
import type {
|
||||
OpenAIToolDefinition,
|
||||
ServerStatus,
|
||||
ToolExecutionResult,
|
||||
MCPToolCall
|
||||
} from '$lib/types/mcp';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { buildMcpClientConfig } from '$lib/utils/mcp';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
|
||||
export type { HealthCheckState };
|
||||
|
||||
class MCPStore {
|
||||
private _isInitializing = $state(false);
|
||||
private _error = $state<string | null>(null);
|
||||
private _configSignature = $state<string | null>(null);
|
||||
private _initPromise: Promise<MCPHostManager | undefined> | null = null;
|
||||
|
||||
// Health check state (in-memory only, not persisted)
|
||||
private _toolCount = $state(0);
|
||||
private _connectedServers = $state<string[]>([]);
|
||||
private _healthChecks = $state<Record<string, HealthCheckState>>({});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Computed Getters
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
constructor() {
|
||||
if (browser) {
|
||||
mcpClient.setStateChangeCallback((state) => {
|
||||
if (state.isInitializing !== undefined) {
|
||||
this._isInitializing = state.isInitializing;
|
||||
}
|
||||
if (state.error !== undefined) {
|
||||
this._error = state.error;
|
||||
}
|
||||
if (state.toolCount !== undefined) {
|
||||
this._toolCount = state.toolCount;
|
||||
}
|
||||
if (state.connectedServers !== undefined) {
|
||||
this._connectedServers = state.connectedServers;
|
||||
}
|
||||
});
|
||||
|
||||
get hostManager(): MCPHostManager | null {
|
||||
return this._hostManager;
|
||||
mcpClient.setHealthCheckCallback((serverId, state) => {
|
||||
this._healthChecks = { ...this._healthChecks, [serverId]: state };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get isInitializing(): boolean {
|
||||
|
|
@ -69,13 +68,25 @@ class MCPStore {
|
|||
}
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return this._hostManager?.isInitialized ?? false;
|
||||
return mcpClient.isInitialized;
|
||||
}
|
||||
|
||||
get error(): string | null {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
get toolCount(): number {
|
||||
return this._toolCount;
|
||||
}
|
||||
|
||||
get connectedServerCount(): number {
|
||||
return this._connectedServers.length;
|
||||
}
|
||||
|
||||
get connectedServerNames(): string[] {
|
||||
return this._connectedServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MCP is enabled (has configured servers)
|
||||
*/
|
||||
|
|
@ -87,245 +98,73 @@ class MCPStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get list of available tool names (aggregated from all servers)
|
||||
* Get list of available tool names
|
||||
*/
|
||||
get availableTools(): string[] {
|
||||
return this._hostManager?.getToolNames() ?? [];
|
||||
return mcpClient.getToolNames();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of connected servers
|
||||
* Ensure MCP is initialized with current config.
|
||||
* @param perChatOverrides - Optional per-chat MCP server overrides
|
||||
*/
|
||||
get connectedServerCount(): number {
|
||||
return this._hostManager?.connectedServerCount ?? 0;
|
||||
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
|
||||
return mcpClient.ensureInitialized(perChatOverrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get names of connected servers
|
||||
* Shutdown MCP connections and clear state
|
||||
*/
|
||||
get connectedServerNames(): string[] {
|
||||
return this._hostManager?.connectedServerNames ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total tool count
|
||||
*/
|
||||
get toolCount(): number {
|
||||
return this._hostManager?.toolCount ?? 0;
|
||||
async shutdown(): Promise<void> {
|
||||
return mcpClient.shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool definitions for LLM (OpenAI function calling format)
|
||||
*/
|
||||
getToolDefinitions(): OpenAIToolDefinition[] {
|
||||
return this._hostManager?.getToolDefinitionsForLLM() ?? [];
|
||||
return mcpClient.getToolDefinitionsForLLM();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all servers
|
||||
*/
|
||||
getServersStatus(): ServerStatus[] {
|
||||
return this._hostManager?.getServersStatus() ?? [];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure MCP host manager is initialized with current config.
|
||||
* Returns the host manager if successful, undefined otherwise.
|
||||
* Handles config changes by reinitializing as needed.
|
||||
* @param perChatOverrides - Optional per-chat MCP server overrides
|
||||
*/
|
||||
async ensureInitialized(
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): Promise<MCPHostManager | undefined> {
|
||||
if (!browser) return undefined;
|
||||
|
||||
const mcpConfig = buildMcpClientConfig(config(), perChatOverrides);
|
||||
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
|
||||
|
||||
// No config - shutdown if needed
|
||||
if (!signature) {
|
||||
await this.shutdown();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Already initialized with correct config
|
||||
if (this._hostManager?.isInitialized && this._configSignature === signature) {
|
||||
return this._hostManager;
|
||||
}
|
||||
|
||||
// Init in progress with correct config - wait for it
|
||||
if (this._initPromise && this._configSignature === signature) {
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
// Config changed or first init - shutdown old manager first
|
||||
if (this._hostManager || this._initPromise) {
|
||||
await this.shutdown();
|
||||
}
|
||||
|
||||
// Initialize new host manager
|
||||
return this.initialize(signature, mcpConfig!);
|
||||
return mcpClient.getServersStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize MCP host manager with given config
|
||||
*/
|
||||
private async initialize(
|
||||
signature: string,
|
||||
mcpConfig: NonNullable<ReturnType<typeof buildMcpClientConfig>>
|
||||
): Promise<MCPHostManager | undefined> {
|
||||
this._isInitializing = true;
|
||||
this._error = null;
|
||||
this._configSignature = signature;
|
||||
|
||||
const hostManager = new MCPHostManager();
|
||||
|
||||
this._initPromise = hostManager
|
||||
.initialize({
|
||||
servers: mcpConfig.servers,
|
||||
clientInfo: mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo,
|
||||
capabilities: mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities
|
||||
})
|
||||
.then(() => {
|
||||
// Check if config changed during initialization
|
||||
if (this._configSignature !== signature) {
|
||||
void hostManager.shutdown().catch((err) => {
|
||||
console.error('[MCP Store] Failed to shutdown stale host manager:', err);
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this._hostManager = hostManager;
|
||||
this._isInitializing = false;
|
||||
|
||||
const toolNames = hostManager.getToolNames();
|
||||
const serverNames = hostManager.connectedServerNames;
|
||||
|
||||
console.log(
|
||||
`[MCP Store] Initialized: ${serverNames.length} servers, ${toolNames.length} tools`
|
||||
);
|
||||
console.log(`[MCP Store] Servers: ${serverNames.join(', ')}`);
|
||||
console.log(`[MCP Store] Tools: ${toolNames.join(', ')}`);
|
||||
|
||||
return hostManager;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[MCP Store] Initialization failed:', error);
|
||||
this._error = error instanceof Error ? error.message : String(error);
|
||||
this._isInitializing = false;
|
||||
|
||||
void hostManager.shutdown().catch((err) => {
|
||||
console.error('[MCP Store] Failed to shutdown after error:', err);
|
||||
});
|
||||
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this._configSignature === signature) {
|
||||
this._initPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown MCP host manager and clear state
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
// Wait for any pending initialization
|
||||
if (this._initPromise) {
|
||||
await this._initPromise.catch(() => {});
|
||||
this._initPromise = null;
|
||||
}
|
||||
|
||||
if (this._hostManager) {
|
||||
const managerToShutdown = this._hostManager;
|
||||
this._hostManager = null;
|
||||
this._configSignature = null;
|
||||
this._error = null;
|
||||
|
||||
try {
|
||||
await managerToShutdown.shutdown();
|
||||
console.log('[MCP Store] Host manager shutdown complete');
|
||||
} catch (error) {
|
||||
console.error('[MCP Store] Shutdown error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tool Execution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a tool call via MCP host manager.
|
||||
* Automatically routes to the appropriate server.
|
||||
* Also tracks usage statistics for the server.
|
||||
* Execute a tool call via MCP.
|
||||
*/
|
||||
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
||||
if (!this._hostManager) {
|
||||
throw new Error('MCP host manager not initialized');
|
||||
}
|
||||
|
||||
// Track usage for the server that provides this tool
|
||||
const serverId = this.getToolServer(toolCall.function.name);
|
||||
if (serverId) {
|
||||
const updatedStats = incrementMcpServerUsage(config(), serverId);
|
||||
settingsStore.updateConfig('mcpServerUsageStats', updatedStats);
|
||||
}
|
||||
|
||||
return this._hostManager.executeTool(toolCall, signal);
|
||||
return mcpClient.executeTool(toolCall, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool by name with arguments.
|
||||
* Simpler interface for direct tool calls.
|
||||
*/
|
||||
async executeToolByName(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolExecutionResult> {
|
||||
if (!this._hostManager) {
|
||||
throw new Error('MCP host manager not initialized');
|
||||
}
|
||||
return this._hostManager.executeToolByName(toolName, args, signal);
|
||||
return mcpClient.executeToolByName(toolName, args, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool exists
|
||||
*/
|
||||
hasTool(toolName: string): boolean {
|
||||
return this._hostManager?.hasTool(toolName) ?? false;
|
||||
return mcpClient.hasTool(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which server provides a specific tool
|
||||
*/
|
||||
getToolServer(toolName: string): string | undefined {
|
||||
return this._hostManager?.getToolServer(toolName);
|
||||
return mcpClient.getToolServer(toolName);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError(): void {
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Health Check (Settings UI)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get health check state for a specific server
|
||||
*/
|
||||
|
|
@ -333,13 +172,6 @@ class MCPStore {
|
|||
return this._healthChecks[serverId] ?? { status: 'idle' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set health check state for a specific server
|
||||
*/
|
||||
private setHealthCheckState(serverId: string, state: HealthCheckState): void {
|
||||
this._healthChecks = { ...this._healthChecks, [serverId]: state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if health check has been performed for a server
|
||||
*/
|
||||
|
|
@ -347,80 +179,11 @@ class MCPStore {
|
|||
return serverId in this._healthChecks && this._healthChecks[serverId].status !== 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse custom headers from JSON string
|
||||
*/
|
||||
private parseHeaders(headersJson?: string): Record<string, string> | undefined {
|
||||
if (!headersJson?.trim()) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(headersJson);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[MCP Store] Failed to parse custom headers JSON:', headersJson);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health check for a specific server
|
||||
*/
|
||||
async runHealthCheck(server: {
|
||||
id: string;
|
||||
url: string;
|
||||
requestTimeoutSeconds: number;
|
||||
headers?: string;
|
||||
}): Promise<void> {
|
||||
const trimmedUrl = server.url.trim();
|
||||
|
||||
if (!trimmedUrl) {
|
||||
this.setHealthCheckState(server.id, {
|
||||
status: 'error',
|
||||
message: 'Please enter a server URL first.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setHealthCheckState(server.id, { status: 'loading' });
|
||||
|
||||
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
|
||||
const headers = this.parseHeaders(server.headers);
|
||||
|
||||
const connection = new MCPServerConnection({
|
||||
name: server.id,
|
||||
server: {
|
||||
url: trimmedUrl,
|
||||
transport: detectMcpTransportFromUrl(trimmedUrl),
|
||||
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
|
||||
requestTimeoutMs: timeoutMs,
|
||||
headers
|
||||
},
|
||||
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
|
||||
capabilities: DEFAULT_MCP_CONFIG.capabilities
|
||||
});
|
||||
|
||||
try {
|
||||
await connection.connect();
|
||||
const tools = connection.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description
|
||||
}));
|
||||
|
||||
this.setHealthCheckState(server.id, { status: 'success', tools });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
this.setHealthCheckState(server.id, { status: 'error', message });
|
||||
} finally {
|
||||
try {
|
||||
await connection.disconnect();
|
||||
} catch (shutdownError) {
|
||||
console.warn(
|
||||
'[MCP Store] Failed to cleanly shutdown health check connection',
|
||||
shutdownError
|
||||
);
|
||||
}
|
||||
}
|
||||
async runHealthCheck(server: HealthCheckParams): Promise<void> {
|
||||
return mcpClient.runHealthCheck(server);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -438,19 +201,17 @@ class MCPStore {
|
|||
clearAllHealthChecks(): void {
|
||||
this._healthChecks = {};
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Singleton Instance & Exports
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError(): void {
|
||||
this._error = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpStore = new MCPStore();
|
||||
|
||||
// Reactive exports for components
|
||||
export function mcpHostManager() {
|
||||
return mcpStore.hostManager;
|
||||
}
|
||||
|
||||
export function mcpIsInitializing() {
|
||||
return mcpStore.isInitializing;
|
||||
}
|
||||
|
|
@ -483,7 +244,6 @@ export function mcpToolCount() {
|
|||
return mcpStore.toolCount;
|
||||
}
|
||||
|
||||
// Health check exports
|
||||
export function mcpGetHealthCheckState(serverId: string) {
|
||||
return mcpStore.getHealthCheckState(serverId);
|
||||
}
|
||||
|
|
@ -492,12 +252,7 @@ export function mcpHasHealthCheck(serverId: string) {
|
|||
return mcpStore.hasHealthCheck(serverId);
|
||||
}
|
||||
|
||||
export async function mcpRunHealthCheck(server: {
|
||||
id: string;
|
||||
url: string;
|
||||
requestTimeoutSeconds: number;
|
||||
headers?: string;
|
||||
}) {
|
||||
export async function mcpRunHealthCheck(server: HealthCheckParams) {
|
||||
return mcpStore.runHealthCheck(server);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { ModelsService } from '$lib/services/models';
|
||||
import { PropsService } from '$lib/services/props';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { ServerModelStatus, ModelModality } from '$lib/enums';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { PropsService } from '$lib/services';
|
||||
|
||||
/**
|
||||
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
|
||||
|
|
@ -32,9 +32,13 @@ import { serverStore } from '$lib/stores/server.svelte';
|
|||
* - **Lazy loading**: ensureModelLoaded() loads models on demand
|
||||
*/
|
||||
class ModelsStore {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* State
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
models = $state<ModelOption[]>([]);
|
||||
routerModels = $state<ApiModelDataEntry[]>([]);
|
||||
|
|
@ -59,9 +63,13 @@ class ModelsStore {
|
|||
*/
|
||||
propsCacheVersion = $state(0);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Computed Getters
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Computed Getters
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
get selectedModel(): ModelOption | null {
|
||||
if (!this.selectedModelId) return null;
|
||||
|
|
@ -95,22 +103,24 @@ class ModelsStore {
|
|||
return props.model_path.split(/(\\|\/)/).pop() || null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Modalities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Modalities
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get modalities for a specific model
|
||||
* Returns cached modalities from model props
|
||||
*/
|
||||
getModelModalities(modelId: string): ModelModalities | null {
|
||||
// First check if modalities are stored in the model option
|
||||
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
|
||||
if (model?.modalities) {
|
||||
return model.modalities;
|
||||
}
|
||||
|
||||
// Fall back to props cache
|
||||
const props = this.modelPropsCache.get(modelId);
|
||||
if (props?.modalities) {
|
||||
return {
|
||||
|
|
@ -181,9 +191,13 @@ class ModelsStore {
|
|||
return this.modelPropsFetching.has(modelId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status Queries
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Status Queries
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
isModelLoaded(modelId: string): boolean {
|
||||
const model = this.routerModels.find((m) => m.id === modelId);
|
||||
|
|
@ -208,9 +222,13 @@ class ModelsStore {
|
|||
return usage !== undefined && usage.size > 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data Fetching
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Data Fetching
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch list of models from server and detect server role
|
||||
|
|
@ -224,7 +242,6 @@ class ModelsStore {
|
|||
this.error = null;
|
||||
|
||||
try {
|
||||
// Ensure server props are loaded (for role detection and MODEL mode modalities)
|
||||
if (!serverStore.props) {
|
||||
await serverStore.fetch();
|
||||
}
|
||||
|
|
@ -251,7 +268,6 @@ class ModelsStore {
|
|||
|
||||
this.models = models;
|
||||
|
||||
// In MODEL mode, populate modalities from serverStore.props (single model)
|
||||
// WORKAROUND: In MODEL mode, /props returns modalities for the single model,
|
||||
// but /v1/models doesn't include modalities. We bridge this gap here.
|
||||
const serverProps = serverStore.props;
|
||||
|
|
@ -260,9 +276,7 @@ class ModelsStore {
|
|||
vision: serverProps.modalities.vision ?? false,
|
||||
audio: serverProps.modalities.audio ?? false
|
||||
};
|
||||
// Cache props for the single model
|
||||
this.modelPropsCache.set(this.models[0].model, serverProps);
|
||||
// Update model with modalities
|
||||
this.models = this.models.map((model, index) =>
|
||||
index === 0 ? { ...model, modalities } : model
|
||||
);
|
||||
|
|
@ -302,7 +316,6 @@ class ModelsStore {
|
|||
* @returns Props data or null if fetch failed or model not loaded
|
||||
*/
|
||||
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
|
||||
// Return cached props if available
|
||||
const cached = this.modelPropsCache.get(modelId);
|
||||
if (cached) return cached;
|
||||
|
||||
|
|
@ -310,7 +323,6 @@ class ModelsStore {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Avoid duplicate fetches
|
||||
if (this.modelPropsFetching.has(modelId)) return null;
|
||||
|
||||
this.modelPropsFetching.add(modelId);
|
||||
|
|
@ -335,7 +347,6 @@ class ModelsStore {
|
|||
const loadedModelIds = this.loadedModelIds;
|
||||
if (loadedModelIds.length === 0) return;
|
||||
|
||||
// Fetch props for each loaded model in parallel
|
||||
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
|
||||
|
||||
try {
|
||||
|
|
@ -357,7 +368,6 @@ class ModelsStore {
|
|||
return { ...model, modalities };
|
||||
});
|
||||
|
||||
// Increment version to trigger reactivity
|
||||
this.propsCacheVersion++;
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch modalities for loaded models:', error);
|
||||
|
|
@ -382,16 +392,19 @@ class ModelsStore {
|
|||
model.model === modelId ? { ...model, modalities } : model
|
||||
);
|
||||
|
||||
// Increment version to trigger reactivity
|
||||
this.propsCacheVersion++;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to update modalities for model ${modelId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Model Selection
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Model Selection
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Select a model for new conversations
|
||||
|
|
@ -443,9 +456,13 @@ class ModelsStore {
|
|||
return this.models.some((model) => model.model === modelName);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Loading/Unloading Models
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Loading/Unloading Models
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* WORKAROUND: Polling for model status after load/unload operations.
|
||||
|
|
@ -486,7 +503,6 @@ class ModelsStore {
|
|||
return;
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
|
||||
}
|
||||
|
||||
|
|
@ -511,8 +527,6 @@ class ModelsStore {
|
|||
|
||||
try {
|
||||
await ModelsService.load(modelId);
|
||||
|
||||
// Poll until model is loaded
|
||||
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
|
||||
|
||||
await this.updateModelModalities(modelId);
|
||||
|
|
@ -562,9 +576,13 @@ class ModelsStore {
|
|||
await this.loadModel(modelId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Utilities
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
private toDisplayName(id: string): string {
|
||||
const segments = id.split(/\\|\//);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PropsService } from '$lib/services/props';
|
||||
import { PropsService } from '$lib/services/props.service';
|
||||
import { ServerRole } from '$lib/enums';
|
||||
|
||||
/**
|
||||
|
|
@ -18,9 +18,13 @@ import { ServerRole } from '$lib/enums';
|
|||
* - **Default Params**: Server-wide generation defaults
|
||||
*/
|
||||
class ServerStore {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* State
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
props = $state<ApiLlamaCppServerProps | null>(null);
|
||||
loading = $state(false);
|
||||
|
|
@ -28,9 +32,13 @@ class ServerStore {
|
|||
role = $state<ServerRole | null>(null);
|
||||
private fetchPromise: Promise<void> | null = null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Getters
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Getters
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
|
||||
return this.props?.default_generation_settings?.params || null;
|
||||
|
|
@ -52,9 +60,13 @@ class ServerStore {
|
|||
return this.role === ServerRole.MODEL;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data Handling
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Data Handling
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
if (this.fetchPromise) return this.fetchPromise;
|
||||
|
|
@ -115,9 +127,13 @@ class ServerStore {
|
|||
this.fetchPromise = null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Utilities
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
private detectRole(props: ApiLlamaCppServerProps): void {
|
||||
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
import { browser } from '$app/environment';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import { ParameterSyncService } from '$lib/services/parameter-sync';
|
||||
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import {
|
||||
configToParameterRecord,
|
||||
|
|
@ -47,18 +47,26 @@ import {
|
|||
} from '$lib/constants/localstorage-keys';
|
||||
|
||||
class SettingsStore {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* State
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
|
||||
theme = $state<string>('auto');
|
||||
isInitialized = $state(false);
|
||||
userOverrides = $state<Set<string>>(new Set());
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities (private helpers)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Utilities (private helpers)
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper method to get server defaults with null safety
|
||||
|
|
@ -76,9 +84,13 @@ class SettingsStore {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Lifecycle
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize the settings store by loading from localStorage
|
||||
|
|
@ -130,9 +142,13 @@ class SettingsStore {
|
|||
|
||||
this.theme = localStorage.getItem('theme') || 'auto';
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Config Updates
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Config Updates
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Update a specific configuration setting
|
||||
|
|
@ -234,9 +250,13 @@ class SettingsStore {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reset
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Reset
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reset configuration to defaults
|
||||
|
|
@ -285,9 +305,13 @@ class SettingsStore {
|
|||
this.saveConfig();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Server Sync
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Server Sync
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize settings with props defaults when server properties are first loaded
|
||||
|
|
@ -349,9 +373,13 @@ class SettingsStore {
|
|||
this.saveConfig();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Utilities
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get a specific configuration value
|
||||
|
|
|
|||
|
|
@ -49,4 +49,37 @@ export interface ChatMessageTimings {
|
|||
predicted_n?: number;
|
||||
prompt_ms?: number;
|
||||
prompt_n?: number;
|
||||
agentic?: ChatMessageAgenticTimings;
|
||||
}
|
||||
|
||||
export interface ChatMessageAgenticTimings {
|
||||
turns: number;
|
||||
toolCallsCount: number;
|
||||
toolsMs: number;
|
||||
toolCalls?: ChatMessageToolCallTiming[];
|
||||
perTurn?: ChatMessageAgenticTurnStats[];
|
||||
llm: {
|
||||
predicted_n: number;
|
||||
predicted_ms: number;
|
||||
prompt_n: number;
|
||||
prompt_ms: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatMessageAgenticTurnStats {
|
||||
turn: number;
|
||||
llm: {
|
||||
predicted_n: number;
|
||||
predicted_ms: number;
|
||||
prompt_n: number;
|
||||
prompt_ms: number;
|
||||
};
|
||||
toolCalls: ChatMessageToolCallTiming[];
|
||||
toolsMs: number;
|
||||
}
|
||||
|
||||
export interface ChatMessageToolCallTiming {
|
||||
name: string;
|
||||
duration_ms: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export interface DatabaseConversation {
|
|||
id: string;
|
||||
lastModified: number;
|
||||
name: string;
|
||||
/** Per-chat MCP server overrides. If not set, global settings are used. */
|
||||
mcpServerOverrides?: McpServerOverride[];
|
||||
}
|
||||
|
||||
|
|
@ -42,9 +41,9 @@ export interface DatabaseMessageExtraPdfFile {
|
|||
type: AttachmentType.PDF;
|
||||
base64Data: string;
|
||||
name: string;
|
||||
content: string; // Text content extracted from PDF
|
||||
images?: string[]; // Optional: PDF pages as base64 images
|
||||
processedAsImages: boolean; // Whether PDF was processed as images
|
||||
content: string;
|
||||
images?: string[];
|
||||
processedAsImages: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseMessageExtraTextFile {
|
||||
|
|
@ -76,17 +75,9 @@ export interface DatabaseMessage {
|
|||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single conversation with its associated messages,
|
||||
* typically used for import/export operations.
|
||||
*/
|
||||
export type ExportedConversation = {
|
||||
conv: DatabaseConversation;
|
||||
messages: DatabaseMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Type representing one or more exported conversations.
|
||||
* Can be a single conversation object or an array of them.
|
||||
*/
|
||||
export type ExportedConversations = ExportedConversation | ExportedConversation[];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import type {
|
||||
ClientCapabilities as SDKClientCapabilities,
|
||||
Implementation as SDKImplementation,
|
||||
Tool,
|
||||
CallToolResult
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type { Tool, CallToolResult };
|
||||
export type ClientCapabilities = SDKClientCapabilities;
|
||||
export type Implementation = SDKImplementation;
|
||||
|
||||
export type MCPTransportType = 'websocket' | 'streamable_http';
|
||||
|
||||
export type MCPServerConfig = {
|
||||
transport?: MCPTransportType;
|
||||
url: string;
|
||||
protocols?: string | string[];
|
||||
headers?: Record<string, string>;
|
||||
credentials?: RequestCredentials;
|
||||
handshakeTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
capabilities?: ClientCapabilities;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type MCPClientConfig = {
|
||||
servers: Record<string, MCPServerConfig>;
|
||||
protocolVersion?: string;
|
||||
capabilities?: ClientCapabilities;
|
||||
clientInfo?: Implementation;
|
||||
requestTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export type MCPToolCallArguments = Record<string, unknown>;
|
||||
|
||||
export type MCPToolCall = {
|
||||
id: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string | MCPToolCallArguments;
|
||||
};
|
||||
};
|
||||
|
||||
export type MCPServerSettingsEntry = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
requestTimeoutSeconds: number;
|
||||
headers?: string;
|
||||
name?: string;
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
export interface MCPHostManagerConfig {
|
||||
servers: MCPClientConfig['servers'];
|
||||
clientInfo?: Implementation;
|
||||
capabilities?: ClientCapabilities;
|
||||
}
|
||||
|
||||
export interface OpenAIToolDefinition {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
name: string;
|
||||
isConnected: boolean;
|
||||
toolCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type McpServerUsageStats = Record<string, number>;
|
||||
|
||||
export interface MCPServerConnectionConfig {
|
||||
name: string;
|
||||
server: MCPServerConfig;
|
||||
clientInfo?: Implementation;
|
||||
capabilities?: ClientCapabilities;
|
||||
}
|
||||
|
||||
export interface ToolCallParams {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionResult {
|
||||
content: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
// Re-export SDK types that we use
|
||||
import type {
|
||||
ClientCapabilities as SDKClientCapabilities,
|
||||
Implementation as SDKImplementation,
|
||||
Tool,
|
||||
CallToolResult
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type { Tool, CallToolResult };
|
||||
export type ClientCapabilities = SDKClientCapabilities;
|
||||
export type Implementation = SDKImplementation;
|
||||
|
||||
export type MCPTransportType = 'websocket' | 'streamable_http';
|
||||
|
||||
export type MCPServerConfig = {
|
||||
/** MCP transport type. Defaults to `streamable_http`. */
|
||||
transport?: MCPTransportType;
|
||||
/** Remote MCP endpoint URL. */
|
||||
url: string;
|
||||
/** Optional WebSocket subprotocol(s). */
|
||||
protocols?: string | string[];
|
||||
/** Optional HTTP headers for environments that support them. */
|
||||
headers?: Record<string, string>;
|
||||
/** Optional credentials policy for fetch-based transports. */
|
||||
credentials?: RequestCredentials;
|
||||
/** Optional handshake timeout override (ms). */
|
||||
handshakeTimeoutMs?: number;
|
||||
/** Optional per-server request timeout override (ms). */
|
||||
requestTimeoutMs?: number;
|
||||
/** Optional per-server capability overrides. */
|
||||
capabilities?: ClientCapabilities;
|
||||
/** Optional pre-negotiated session identifier for Streamable HTTP transport. */
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type MCPClientConfig = {
|
||||
servers: Record<string, MCPServerConfig>;
|
||||
/** Defaults to `2025-06-18`. */
|
||||
protocolVersion?: string;
|
||||
/** Default capabilities advertised during initialize. */
|
||||
capabilities?: ClientCapabilities;
|
||||
/** Custom client info to advertise. */
|
||||
clientInfo?: Implementation;
|
||||
/** Request timeout when waiting for MCP responses (ms). Default: 30_000. */
|
||||
requestTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export type MCPToolCallArguments = Record<string, unknown>;
|
||||
|
||||
export type MCPToolCall = {
|
||||
id: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string | MCPToolCallArguments;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw MCP server configuration entry stored in settings.
|
||||
*/
|
||||
export type MCPServerSettingsEntry = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
requestTimeoutSeconds: number;
|
||||
/** Optional custom HTTP headers (JSON string of key-value pairs). */
|
||||
headers?: string;
|
||||
/** Server name from metadata (fetched during health check). */
|
||||
name?: string;
|
||||
/** Server icon URL from metadata (fetched during health check). */
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Host Manager Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MCPHostManagerConfig {
|
||||
/** Server configurations keyed by server name */
|
||||
servers: MCPClientConfig['servers'];
|
||||
/** Client info to advertise to all servers */
|
||||
clientInfo?: Implementation;
|
||||
/** Default capabilities to advertise */
|
||||
capabilities?: ClientCapabilities;
|
||||
}
|
||||
|
||||
export interface OpenAIToolDefinition {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
name: string;
|
||||
isConnected: boolean;
|
||||
toolCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Usage Stats
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type McpServerUsageStats = Record<string, number>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Server Connection Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MCPServerConnectionConfig {
|
||||
/** Unique server name/identifier */
|
||||
name: string;
|
||||
/** Server configuration */
|
||||
server: MCPServerConfig;
|
||||
/** Client info to advertise */
|
||||
clientInfo?: Implementation;
|
||||
/** Capabilities to advertise */
|
||||
capabilities?: ClientCapabilities;
|
||||
}
|
||||
|
||||
export interface ToolCallParams {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionResult {
|
||||
content: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
|
||||
|
||||
/**
|
||||
* Model modalities - vision and audio capabilities
|
||||
*/
|
||||
export interface ModelModalities {
|
||||
vision: boolean;
|
||||
audio: boolean;
|
||||
|
|
@ -14,7 +11,6 @@ export interface ModelOption {
|
|||
model: string;
|
||||
description?: string;
|
||||
capabilities: string[];
|
||||
/** Model modalities from /props endpoint */
|
||||
modalities?: ModelModalities;
|
||||
details?: ApiModelDetails['details'];
|
||||
meta?: ApiModelDataEntry['meta'];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import type { ChatMessageTimings } from './chat';
|
||||
import type { OpenAIToolDefinition } from './mcp';
|
||||
|
||||
export type SettingsConfigValue = string | number | boolean;
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ export interface SettingsChatServiceOptions {
|
|||
systemMessage?: string;
|
||||
// Disable reasoning parsing (use 'none' instead of 'auto')
|
||||
disableReasoningParsing?: boolean;
|
||||
tools?: OpenAIToolDefinition[];
|
||||
// Generation parameters
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,41 @@
|
|||
import type { ApiChatMessageData } from '$lib/types/api';
|
||||
import type { AgenticMessage } from '$lib/types/agentic';
|
||||
import type { AgenticMessage, AgenticConfig } from '$lib/types/agentic';
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
|
||||
import { normalizePositiveNumber } from '$lib/utils/number';
|
||||
import { hasEnabledMcpServers } from '$lib/utils/mcp';
|
||||
|
||||
/**
|
||||
* Gets the current agentic configuration.
|
||||
* Automatically disables agentic mode if no MCP servers are configured.
|
||||
* @param settings - Global settings configuration
|
||||
* @param perChatOverrides - Optional per-chat MCP server overrides
|
||||
*/
|
||||
export function getAgenticConfig(
|
||||
settings: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): AgenticConfig {
|
||||
const maxTurns = normalizePositiveNumber(
|
||||
settings.agenticMaxTurns,
|
||||
DEFAULT_AGENTIC_CONFIG.maxTurns
|
||||
);
|
||||
const maxToolPreviewLines = normalizePositiveNumber(
|
||||
settings.agenticMaxToolPreviewLines,
|
||||
DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines
|
||||
);
|
||||
const filterReasoningAfterFirstTurn =
|
||||
typeof settings.agenticFilterReasoningAfterFirstTurn === 'boolean'
|
||||
? settings.agenticFilterReasoningAfterFirstTurn
|
||||
: DEFAULT_AGENTIC_CONFIG.filterReasoningAfterFirstTurn;
|
||||
|
||||
return {
|
||||
enabled: hasEnabledMcpServers(settings, perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
maxTurns,
|
||||
maxToolPreviewLines,
|
||||
filterReasoningAfterFirstTurn
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts API messages to agentic format.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Decode base64 to UTF-8 string using modern APIs.
|
||||
* Falls back to legacy method if TextDecoder is unavailable.
|
||||
*
|
||||
* @param base64 - Base64 encoded string (padding is optional)
|
||||
* @returns Decoded UTF-8 string, or empty string if decoding fails
|
||||
*/
|
||||
export function decodeBase64(base64: string): string {
|
||||
if (!base64) return '';
|
||||
|
||||
const padded = base64 + '=='.slice(0, (4 - (base64.length % 4)) % 4);
|
||||
|
||||
try {
|
||||
const binaryString = atob(padded);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
} catch {
|
||||
// Fallback to legacy method
|
||||
try {
|
||||
return decodeURIComponent(escape(atob(padded)));
|
||||
} catch {
|
||||
// Return empty string if all decoding fails
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,3 +97,6 @@ export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files
|
|||
|
||||
// Agentic utilities
|
||||
export { toAgenticMessages } from './agentic';
|
||||
|
||||
// Base64 utilities
|
||||
export { decodeBase64 } from './base64';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import type { MCPTransportType } from '$lib/types/mcp';
|
||||
import type {
|
||||
MCPTransportType,
|
||||
MCPClientConfig,
|
||||
MCPServerConfig,
|
||||
MCPServerSettingsEntry,
|
||||
McpServerUsageStats
|
||||
} from '$lib/types/mcp';
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
import { normalizePositiveNumber } from '$lib/utils/number';
|
||||
|
||||
/**
|
||||
* Represents a key-value pair for HTTP headers.
|
||||
|
|
@ -73,7 +83,7 @@ export function parseHeadersToArray(headersJson: string): HeaderPair[] {
|
|||
}));
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON, return empty
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
@ -91,3 +101,199 @@ export function serializeHeaders(pairs: HeaderPair[]): string {
|
|||
}
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses MCP server settings from a JSON string or array.
|
||||
* @param rawServers - The raw servers to parse
|
||||
* @param fallbackRequestTimeoutSeconds - The fallback request timeout seconds
|
||||
* @returns An empty array if the input is invalid.
|
||||
*/
|
||||
export function parseMcpServerSettings(
|
||||
rawServers: unknown,
|
||||
fallbackRequestTimeoutSeconds = DEFAULT_MCP_CONFIG.requestTimeoutSeconds
|
||||
): MCPServerSettingsEntry[] {
|
||||
if (!rawServers) return [];
|
||||
|
||||
let parsed: unknown;
|
||||
if (typeof rawServers === 'string') {
|
||||
const trimmed = rawServers.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
parsed = rawServers;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.map((entry, index) => {
|
||||
const requestTimeoutSeconds = normalizePositiveNumber(
|
||||
(entry as { requestTimeoutSeconds?: unknown })?.requestTimeoutSeconds,
|
||||
fallbackRequestTimeoutSeconds
|
||||
);
|
||||
|
||||
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
|
||||
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
|
||||
|
||||
return {
|
||||
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
|
||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||
url,
|
||||
requestTimeoutSeconds,
|
||||
headers: headers || undefined
|
||||
} satisfies MCPServerSettingsEntry;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an MCP server configuration from a server settings entry.
|
||||
* @param entry - The server settings entry to build the configuration from
|
||||
* @param connectionTimeoutMs - The connection timeout in milliseconds
|
||||
* @returns The built server configuration, or undefined if the entry is invalid
|
||||
*/
|
||||
function buildServerConfig(
|
||||
entry: MCPServerSettingsEntry,
|
||||
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
|
||||
): MCPServerConfig | undefined {
|
||||
if (!entry?.url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let headers: Record<string, string> | undefined;
|
||||
if (entry.headers) {
|
||||
try {
|
||||
const parsed = JSON.parse(entry.headers);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
headers = parsed as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[MCP] Failed to parse custom headers JSON, ignoring:', entry.headers);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: entry.url,
|
||||
transport: detectMcpTransportFromUrl(entry.url),
|
||||
handshakeTimeoutMs: connectionTimeoutMs,
|
||||
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
|
||||
headers
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO - move stateful logic to store
|
||||
*/
|
||||
function isServerEnabled(
|
||||
server: MCPServerSettingsEntry,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): boolean {
|
||||
if (perChatOverrides) {
|
||||
const override = perChatOverrides.find((o) => o.serverId === server.id);
|
||||
if (override !== undefined) {
|
||||
return override.enabled;
|
||||
}
|
||||
}
|
||||
return server.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds MCP client configuration from settings.
|
||||
* Returns undefined if no valid servers are configured.
|
||||
* @param config - Global settings configuration
|
||||
* @param perChatOverrides - Optional per-chat server overrides
|
||||
*/
|
||||
export function buildMcpClientConfig(
|
||||
config: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): MCPClientConfig | undefined {
|
||||
const rawServers = parseMcpServerSettings(config.mcpServers);
|
||||
|
||||
if (!rawServers.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const servers: Record<string, MCPServerConfig> = {};
|
||||
for (const [index, entry] of rawServers.entries()) {
|
||||
if (!isServerEnabled(entry, perChatOverrides)) continue;
|
||||
|
||||
const normalized = buildServerConfig(entry);
|
||||
if (normalized) {
|
||||
servers[generateMcpServerId(entry.id, index)] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(servers).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
|
||||
capabilities: DEFAULT_MCP_CONFIG.capabilities,
|
||||
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
|
||||
requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
|
||||
servers
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO - move stateful logic to store
|
||||
*/
|
||||
export function hasEnabledMcpServers(
|
||||
config: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): boolean {
|
||||
return Boolean(buildMcpClientConfig(config, perChatOverrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses MCP server usage stats from settings.
|
||||
* @param rawStats - The raw stats to parse
|
||||
* @returns MCP server usage stats or empty object if invalid
|
||||
*/
|
||||
export function parseMcpServerUsageStats(rawStats: unknown): McpServerUsageStats {
|
||||
if (!rawStats) return {};
|
||||
|
||||
if (typeof rawStats === 'string') {
|
||||
const trimmed = rawStats.trim();
|
||||
if (!trimmed) return {};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as McpServerUsageStats;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[MCP] Failed to parse mcpServerUsageStats JSON, ignoring value');
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets usage count for a specific server.
|
||||
* @param config - Global settings configuration
|
||||
* @param serverId - The server ID to get the usage count for
|
||||
* @returns The usage count for the server
|
||||
*/
|
||||
export function getMcpServerUsageCount(config: SettingsConfigType, serverId: string): number {
|
||||
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
|
||||
return stats[serverId] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments usage count for a server and returns updated stats JSON.
|
||||
* @param config - Global settings configuration
|
||||
* @param serverId - The server ID to increment the usage count for
|
||||
* @returns The updated stats JSON
|
||||
*/
|
||||
export function incrementMcpServerUsage(config: SettingsConfigType, serverId: string): string {
|
||||
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
|
||||
stats[serverId] = (stats[serverId] || 0) + 1;
|
||||
return JSON.stringify(stats);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue