refactor: Cleanup
This commit is contained in:
parent
96e51e2a41
commit
5ffb6aba3a
|
|
@ -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
|
SyntaxHighlightedCode
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
|
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
|
||||||
import { Wrench, Loader2 } from '@lucide/svelte';
|
import { Wrench, Loader2 } from '@lucide/svelte';
|
||||||
import { AgenticSectionType } from '$lib/enums';
|
import { AgenticSectionType } from '$lib/enums';
|
||||||
import { AGENTIC_TAGS, AGENTIC_REGEX } from '$lib/constants/agentic';
|
import { AGENTIC_TAGS, AGENTIC_REGEX } from '$lib/constants/agentic';
|
||||||
import { formatJsonPretty } from '$lib/utils/formatters';
|
import { formatJsonPretty } from '$lib/utils/formatters';
|
||||||
|
import { decodeBase64 } from '$lib/utils';
|
||||||
|
import type { ChatMessageToolCallTiming } from '$lib/types/chat';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
toolCallTimings?: ChatMessageToolCallTiming[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgenticSection {
|
interface AgenticSection {
|
||||||
|
|
@ -30,10 +35,18 @@
|
||||||
toolResult?: string;
|
toolResult?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { content }: Props = $props();
|
let { content, isStreaming = false, toolCallTimings = [] }: Props = $props();
|
||||||
|
|
||||||
const sections = $derived(parseAgenticContent(content));
|
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({});
|
let expandedStates: Record<number, boolean> = $state({});
|
||||||
|
|
||||||
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
|
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
|
||||||
|
|
@ -74,12 +87,7 @@
|
||||||
|
|
||||||
const toolName = match[1];
|
const toolName = match[1];
|
||||||
const toolArgsBase64 = match[2];
|
const toolArgsBase64 = match[2];
|
||||||
let toolArgs = '';
|
const toolArgs = decodeBase64(toolArgsBase64);
|
||||||
try {
|
|
||||||
toolArgs = decodeURIComponent(escape(atob(toolArgsBase64)));
|
|
||||||
} catch {
|
|
||||||
toolArgs = toolArgsBase64;
|
|
||||||
}
|
|
||||||
const toolResult = match[3].replace(/^\n+|\n+$/g, '');
|
const toolResult = match[3].replace(/^\n+|\n+$/g, '');
|
||||||
|
|
||||||
sections.push({
|
sections.push({
|
||||||
|
|
@ -112,12 +120,7 @@
|
||||||
|
|
||||||
const toolName = pendingMatch[1];
|
const toolName = pendingMatch[1];
|
||||||
const toolArgsBase64 = pendingMatch[2];
|
const toolArgsBase64 = pendingMatch[2];
|
||||||
let toolArgs = '';
|
const toolArgs = decodeBase64(toolArgsBase64);
|
||||||
try {
|
|
||||||
toolArgs = decodeURIComponent(escape(atob(toolArgsBase64)));
|
|
||||||
} catch {
|
|
||||||
toolArgs = toolArgsBase64;
|
|
||||||
}
|
|
||||||
// Capture streaming result content (everything after args marker)
|
// Capture streaming result content (everything after args marker)
|
||||||
const streamingResult = (pendingMatch[3] || '').replace(/^\n+|\n+$/g, '');
|
const streamingResult = (pendingMatch[3] || '').replace(/^\n+|\n+$/g, '');
|
||||||
|
|
||||||
|
|
@ -138,23 +141,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const partialArgsBase64 = partialWithNameMatch[2] || '';
|
const partialArgsBase64 = partialWithNameMatch[2] || '';
|
||||||
let partialArgs = '';
|
const partialArgs = decodeBase64(partialArgsBase64);
|
||||||
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 = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.push({
|
sections.push({
|
||||||
type: AgenticSectionType.TOOL_CALL_STREAMING,
|
type: AgenticSectionType.TOOL_CALL_STREAMING,
|
||||||
|
|
@ -214,46 +201,25 @@
|
||||||
<div class="agentic-text">
|
<div class="agentic-text">
|
||||||
<MarkdownContent content={section.content} />
|
<MarkdownContent content={section.content} />
|
||||||
</div>
|
</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}
|
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
||||||
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
||||||
{@const toolIcon = isPending ? Loader2 : Wrench}
|
{@const toolIcon = isPending ? Loader2 : Wrench}
|
||||||
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
|
{@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
|
<CollapsibleContentBlock
|
||||||
open={isExpanded(index, isPending)}
|
open={isExpanded(index, isPending)}
|
||||||
class="my-2"
|
class="my-2"
|
||||||
icon={toolIcon}
|
icon={toolIcon}
|
||||||
iconClass={toolIconClass}
|
iconClass={toolIconClass}
|
||||||
title={section.toolName || ''}
|
title={section.toolName || ''}
|
||||||
subtitle={isPending ? 'executing...' : undefined}
|
subtitle={isPending
|
||||||
|
? 'executing...'
|
||||||
|
: timing
|
||||||
|
? `${(timing.duration_ms / 1000).toFixed(2)}s`
|
||||||
|
: undefined}
|
||||||
onToggle={() => toggleExpanded(index, isPending)}
|
onToggle={() => toggleExpanded(index, isPending)}
|
||||||
>
|
>
|
||||||
{#if section.toolArgs && section.toolArgs !== '{}'}
|
{#if section.toolArgs && section.toolArgs !== '{}'}
|
||||||
|
|
@ -289,6 +255,37 @@
|
||||||
</CollapsibleContentBlock>
|
</CollapsibleContentBlock>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||||
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.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 { autoResizeTextarea, copyToClipboard } from '$lib/utils';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { Check, X } from '@lucide/svelte';
|
import { Check, X } from '@lucide/svelte';
|
||||||
|
|
@ -82,25 +83,18 @@
|
||||||
thinkingContent
|
thinkingContent
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Check if content contains agentic tool call markers
|
const hasAgenticMarkers = $derived(
|
||||||
const isAgenticContent = $derived(
|
|
||||||
messageContent?.includes('<<<AGENTIC_TOOL_CALL_START>>>') ?? false
|
messageContent?.includes('<<<AGENTIC_TOOL_CALL_START>>>') ?? false
|
||||||
);
|
);
|
||||||
|
const hasStreamingToolCall = $derived(isChatStreaming() && agenticStreamingToolCall() !== null);
|
||||||
|
const isAgenticContent = $derived(hasAgenticMarkers || hasStreamingToolCall);
|
||||||
const processingState = useProcessingState();
|
const processingState = useProcessingState();
|
||||||
|
|
||||||
// Local state for raw output toggle (per message)
|
|
||||||
let showRawOutput = $state(false);
|
|
||||||
|
|
||||||
let currentConfig = $derived(config());
|
let currentConfig = $derived(config());
|
||||||
let isRouter = $derived(isRouterMode());
|
let isRouter = $derived(isRouterMode());
|
||||||
let displayedModel = $derived((): string | null => {
|
let showRawOutput = $state(false);
|
||||||
if (message.model) {
|
|
||||||
return message.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
let displayedModel = $derived(message.model ?? null);
|
||||||
});
|
|
||||||
|
|
||||||
const { handleModelChange } = useModelChangeValidation({
|
const { handleModelChange } = useModelChangeValidation({
|
||||||
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
|
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
|
||||||
|
|
@ -108,9 +102,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleCopyModel() {
|
function handleCopyModel() {
|
||||||
const model = displayedModel();
|
void copyToClipboard(displayedModel ?? '');
|
||||||
|
|
||||||
void copyToClipboard(model ?? '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -191,7 +183,11 @@
|
||||||
{#if showRawOutput}
|
{#if showRawOutput}
|
||||||
<pre class="raw-output">{messageContent || ''}</pre>
|
<pre class="raw-output">{messageContent || ''}</pre>
|
||||||
{:else if isAgenticContent}
|
{:else if isAgenticContent}
|
||||||
<AgenticContent content={messageContent || ''} />
|
<AgenticContent
|
||||||
|
content={messageContent || ''}
|
||||||
|
isStreaming={isChatStreaming()}
|
||||||
|
toolCallTimings={message.timings?.agentic?.toolCalls}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<MarkdownContent content={messageContent || ''} />
|
<MarkdownContent content={messageContent || ''} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -201,18 +197,18 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="info my-6 grid gap-4 tabular-nums">
|
<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">
|
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||||
{#if isRouter}
|
{#if isRouter}
|
||||||
<ModelsSelector
|
<ModelsSelector
|
||||||
currentModel={displayedModel()}
|
currentModel={displayedModel}
|
||||||
onModelChange={handleModelChange}
|
onModelChange={handleModelChange}
|
||||||
disabled={isLoading()}
|
disabled={isLoading()}
|
||||||
upToMessageId={message.id}
|
upToMessageId={message.id}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
|
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||||
|
|
@ -221,6 +217,7 @@
|
||||||
promptMs={message.timings.prompt_ms}
|
promptMs={message.timings.prompt_ms}
|
||||||
predictedTokens={message.timings.predicted_n}
|
predictedTokens={message.timings.predicted_n}
|
||||||
predictedMs={message.timings.predicted_ms}
|
predictedMs={message.timings.predicted_ms}
|
||||||
|
agenticTimings={message.timings.agentic}
|
||||||
/>
|
/>
|
||||||
{:else if isLoading() && currentConfig.showMessageStats}
|
{:else if isLoading() && currentConfig.showMessageStats}
|
||||||
{@const liveStats = processingState.getLiveProcessingStats()}
|
{@const liveStats = processingState.getLiveProcessingStats()}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
<script lang="ts">
|
<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 { BadgeChatStatistic } from '$lib/components/app';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { ChatMessageStatsView } from '$lib/enums';
|
import { ChatMessageStatsView } from '$lib/enums';
|
||||||
import { formatPerformanceTime } from '$lib/utils/formatters';
|
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
predictedTokens?: number;
|
predictedTokens?: number;
|
||||||
predictedMs?: number;
|
predictedMs?: number;
|
||||||
promptTokens?: number;
|
promptTokens?: number;
|
||||||
promptMs?: number;
|
promptMs?: number;
|
||||||
// Live mode: when true, shows stats during streaming
|
|
||||||
isLive?: boolean;
|
isLive?: boolean;
|
||||||
// Whether prompt processing is still in progress
|
|
||||||
isProcessingPrompt?: boolean;
|
isProcessingPrompt?: boolean;
|
||||||
// Initial view to show (defaults to READING in live mode)
|
|
||||||
initialView?: ChatMessageStatsView;
|
initialView?: ChatMessageStatsView;
|
||||||
|
agenticTimings?: ChatMessageAgenticTimings;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -25,7 +23,8 @@
|
||||||
promptMs,
|
promptMs,
|
||||||
isLive = false,
|
isLive = false,
|
||||||
isProcessingPrompt = false,
|
isProcessingPrompt = false,
|
||||||
initialView = ChatMessageStatsView.GENERATION
|
initialView = ChatMessageStatsView.GENERATION,
|
||||||
|
agenticTimings
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let activeView: ChatMessageStatsView = $state(initialView);
|
let activeView: ChatMessageStatsView = $state(initialView);
|
||||||
|
|
@ -81,6 +80,26 @@
|
||||||
|
|
||||||
// In live mode, generation tab is disabled until we have generation stats
|
// In live mode, generation tab is disabled until we have generation stats
|
||||||
let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="inline-flex items-center text-xs text-muted-foreground">
|
<div class="inline-flex items-center text-xs text-muted-foreground">
|
||||||
|
|
@ -130,6 +149,44 @@
|
||||||
</p>
|
</p>
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</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>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 px-2">
|
<div class="flex items-center gap-1 px-2">
|
||||||
|
|
@ -149,9 +206,47 @@
|
||||||
<BadgeChatStatistic
|
<BadgeChatStatistic
|
||||||
class="bg-transparent"
|
class="bg-transparent"
|
||||||
icon={Gauge}
|
icon={Gauge}
|
||||||
value="{tokensPerSecond.toFixed(2)} tokens/s"
|
value="{tokensPerSecond.toFixed(2)} t/s"
|
||||||
tooltipLabel="Generation speed"
|
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}
|
{:else if hasPromptStats}
|
||||||
<BadgeChatStatistic
|
<BadgeChatStatistic
|
||||||
class="bg-transparent"
|
class="bg-transparent"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
|
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
|
||||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.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 type { MCPServerSettingsEntry } from '$lib/types/mcp';
|
||||||
import {
|
import {
|
||||||
mcpGetHealthCheckState,
|
mcpGetHealthCheckState,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { Plus, X } from '@lucide/svelte';
|
import { Plus, X } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
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 { MCPServerSettingsEntry } from '$lib/types/mcp';
|
||||||
import type { SettingsConfigType } from '$lib/types/settings';
|
import type { SettingsConfigType } from '$lib/types/settings';
|
||||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@
|
||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
isOpen ? 'text-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}
|
disabled={disabled || updating}
|
||||||
>
|
>
|
||||||
<Package class="h-3.5 w-3.5" />
|
<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);
|
|
||||||
}
|
|
||||||
|
|
@ -189,7 +189,7 @@ export function useProcessingState(): UseProcessingStateReturn {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
|
if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
|
||||||
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`);
|
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} t/s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stateToUse.speculative) {
|
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)
|
* - Request lifecycle management (abort via AbortSignal)
|
||||||
*/
|
*/
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Messaging
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Messaging
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a chat completion request to the llama.cpp server.
|
* Sends a chat completion request to the llama.cpp server.
|
||||||
|
|
@ -63,6 +67,8 @@ export class ChatService {
|
||||||
onToolCallChunk,
|
onToolCallChunk,
|
||||||
onModel,
|
onModel,
|
||||||
onTimings,
|
onTimings,
|
||||||
|
// Tools for function calling
|
||||||
|
tools,
|
||||||
// Generation parameters
|
// Generation parameters
|
||||||
temperature,
|
temperature,
|
||||||
max_tokens,
|
max_tokens,
|
||||||
|
|
@ -116,10 +122,13 @@ export class ChatService {
|
||||||
const requestBody: ApiChatCompletionRequest = {
|
const requestBody: ApiChatCompletionRequest = {
|
||||||
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
|
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
|
||||||
role: msg.role,
|
role: msg.role,
|
||||||
content: msg.content
|
content: msg.content,
|
||||||
|
tool_calls: msg.tool_calls,
|
||||||
|
tool_call_id: msg.tool_call_id
|
||||||
})),
|
})),
|
||||||
stream,
|
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)
|
// 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
|
* Handles streaming response from the chat completion API
|
||||||
|
|
@ -309,6 +322,8 @@ export class ChatService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[ChatService] Tool call delta received:', JSON.stringify(toolCalls));
|
||||||
|
|
||||||
aggregatedToolCalls = ChatService.mergeToolCallDeltas(
|
aggregatedToolCalls = ChatService.mergeToolCallDeltas(
|
||||||
aggregatedToolCalls,
|
aggregatedToolCalls,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
|
@ -323,6 +338,8 @@ export class ChatService {
|
||||||
|
|
||||||
const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
|
const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
|
||||||
|
|
||||||
|
console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
|
||||||
|
|
||||||
if (!serializedToolCalls) {
|
if (!serializedToolCalls) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -563,9 +580,13 @@ export class ChatService {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Conversion
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Conversion
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a database message with attachments to API chat message format.
|
* 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
|
* Parses error response and creates appropriate error with context information
|
||||||
|
|
@ -17,8 +17,54 @@ class LlamacppDatabase extends Dexie {
|
||||||
|
|
||||||
const db = new LlamacppDatabase();
|
const db = new LlamacppDatabase();
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { MessageRole } from '$lib/enums/chat';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DatabaseService - Stateless IndexedDB communication layer
|
||||||
|
*
|
||||||
|
* **Terminology - Chat vs Conversation:**
|
||||||
|
* - **Chat**: The active interaction space with the Chat Completions API (ephemeral, runtime).
|
||||||
|
* - **Conversation**: The persistent database entity storing all messages and metadata.
|
||||||
|
* This service handles raw database operations for conversations - the lowest layer
|
||||||
|
* in the persistence stack.
|
||||||
|
*
|
||||||
|
* This service provides a stateless data access layer built on IndexedDB using Dexie ORM.
|
||||||
|
* It handles all low-level storage operations for conversations and messages with support
|
||||||
|
* for complex branching and message threading. All methods are static - no instance state.
|
||||||
|
*
|
||||||
|
* **Architecture & Relationships (bottom to top):**
|
||||||
|
* - **DatabaseService** (this class): Stateless IndexedDB operations
|
||||||
|
* - Lowest layer - direct Dexie/IndexedDB communication
|
||||||
|
* - Pure CRUD operations without business logic
|
||||||
|
* - Handles branching tree structure (parent-child relationships)
|
||||||
|
* - Provides transaction safety for multi-table operations
|
||||||
|
*
|
||||||
|
* - **ConversationsService**: Stateless business logic layer
|
||||||
|
* - Uses DatabaseService for all persistence operations
|
||||||
|
* - Adds import/export, navigation, and higher-level operations
|
||||||
|
*
|
||||||
|
* - **conversationsStore**: Reactive state management for conversations
|
||||||
|
* - Uses ConversationsService for database operations
|
||||||
|
* - Manages conversation list, active conversation, and messages in memory
|
||||||
|
*
|
||||||
|
* - **chatStore**: Active AI interaction management
|
||||||
|
* - Uses conversationsStore for conversation context
|
||||||
|
* - Directly uses DatabaseService for message CRUD during streaming
|
||||||
|
*
|
||||||
|
* **Key Features:**
|
||||||
|
* - **Conversation CRUD**: Create, read, update, delete conversations
|
||||||
|
* - **Message CRUD**: Add, update, delete messages with branching support
|
||||||
|
* - **Branch Operations**: Create branches, find descendants, cascade deletions
|
||||||
|
* - **Transaction Safety**: Atomic operations for data consistency
|
||||||
|
*
|
||||||
|
* **Database Schema:**
|
||||||
|
* - `conversations`: id, lastModified, currNode, name
|
||||||
|
* - `messages`: id, convId, type, role, timestamp, parent, children
|
||||||
|
*
|
||||||
|
* **Branching Model:**
|
||||||
|
* Messages form a tree structure where each message can have multiple children,
|
||||||
|
* enabling conversation branching and alternative response paths. The conversation's
|
||||||
|
* `currNode` tracks the currently active branch endpoint.
|
||||||
|
*/
|
||||||
export class DatabaseService {
|
export class DatabaseService {
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|
@ -116,9 +162,10 @@ export class DatabaseService {
|
||||||
convId,
|
convId,
|
||||||
type: 'root',
|
type: 'root',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
role: MessageRole.SYSTEM,
|
role: 'system',
|
||||||
content: '',
|
content: '',
|
||||||
parent: null,
|
parent: null,
|
||||||
|
thinking: '',
|
||||||
toolCalls: '',
|
toolCalls: '',
|
||||||
children: []
|
children: []
|
||||||
};
|
};
|
||||||
|
|
@ -149,11 +196,12 @@ export class DatabaseService {
|
||||||
const systemMessage: DatabaseMessage = {
|
const systemMessage: DatabaseMessage = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
convId,
|
convId,
|
||||||
type: MessageRole.SYSTEM,
|
type: 'system',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
role: MessageRole.SYSTEM,
|
role: 'system',
|
||||||
content: trimmedPrompt,
|
content: trimmedPrompt,
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
|
thinking: '',
|
||||||
children: []
|
children: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,400 +0,0 @@
|
||||||
import Dexie, { type EntityTable } from 'dexie';
|
|
||||||
import { findDescendantMessages } from '$lib/utils';
|
|
||||||
|
|
||||||
class LlamacppDatabase extends Dexie {
|
|
||||||
conversations!: EntityTable<DatabaseConversation, string>;
|
|
||||||
messages!: EntityTable<DatabaseMessage, string>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super('LlamacppWebui');
|
|
||||||
|
|
||||||
this.version(1).stores({
|
|
||||||
conversations: 'id, lastModified, currNode, name',
|
|
||||||
messages: 'id, convId, type, role, timestamp, parent, children'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new LlamacppDatabase();
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DatabaseService - Stateless IndexedDB communication layer
|
|
||||||
*
|
|
||||||
* **Terminology - Chat vs Conversation:**
|
|
||||||
* - **Chat**: The active interaction space with the Chat Completions API (ephemeral, runtime).
|
|
||||||
* - **Conversation**: The persistent database entity storing all messages and metadata.
|
|
||||||
* This service handles raw database operations for conversations - the lowest layer
|
|
||||||
* in the persistence stack.
|
|
||||||
*
|
|
||||||
* This service provides a stateless data access layer built on IndexedDB using Dexie ORM.
|
|
||||||
* It handles all low-level storage operations for conversations and messages with support
|
|
||||||
* for complex branching and message threading. All methods are static - no instance state.
|
|
||||||
*
|
|
||||||
* **Architecture & Relationships (bottom to top):**
|
|
||||||
* - **DatabaseService** (this class): Stateless IndexedDB operations
|
|
||||||
* - Lowest layer - direct Dexie/IndexedDB communication
|
|
||||||
* - Pure CRUD operations without business logic
|
|
||||||
* - Handles branching tree structure (parent-child relationships)
|
|
||||||
* - Provides transaction safety for multi-table operations
|
|
||||||
*
|
|
||||||
* - **ConversationsService**: Stateless business logic layer
|
|
||||||
* - Uses DatabaseService for all persistence operations
|
|
||||||
* - Adds import/export, navigation, and higher-level operations
|
|
||||||
*
|
|
||||||
* - **conversationsStore**: Reactive state management for conversations
|
|
||||||
* - Uses ConversationsService for database operations
|
|
||||||
* - Manages conversation list, active conversation, and messages in memory
|
|
||||||
*
|
|
||||||
* - **chatStore**: Active AI interaction management
|
|
||||||
* - Uses conversationsStore for conversation context
|
|
||||||
* - Directly uses DatabaseService for message CRUD during streaming
|
|
||||||
*
|
|
||||||
* **Key Features:**
|
|
||||||
* - **Conversation CRUD**: Create, read, update, delete conversations
|
|
||||||
* - **Message CRUD**: Add, update, delete messages with branching support
|
|
||||||
* - **Branch Operations**: Create branches, find descendants, cascade deletions
|
|
||||||
* - **Transaction Safety**: Atomic operations for data consistency
|
|
||||||
*
|
|
||||||
* **Database Schema:**
|
|
||||||
* - `conversations`: id, lastModified, currNode, name
|
|
||||||
* - `messages`: id, convId, type, role, timestamp, parent, children
|
|
||||||
*
|
|
||||||
* **Branching Model:**
|
|
||||||
* Messages form a tree structure where each message can have multiple children,
|
|
||||||
* enabling conversation branching and alternative response paths. The conversation's
|
|
||||||
* `currNode` tracks the currently active branch endpoint.
|
|
||||||
*/
|
|
||||||
export class DatabaseService {
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Conversations
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new conversation.
|
|
||||||
*
|
|
||||||
* @param name - Name of the conversation
|
|
||||||
* @returns The created conversation
|
|
||||||
*/
|
|
||||||
static async createConversation(name: string): Promise<DatabaseConversation> {
|
|
||||||
const conversation: DatabaseConversation = {
|
|
||||||
id: uuid(),
|
|
||||||
name,
|
|
||||||
lastModified: Date.now(),
|
|
||||||
currNode: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.conversations.add(conversation);
|
|
||||||
return conversation;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Messages
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new message branch by adding a message and updating parent/child relationships.
|
|
||||||
* Also updates the conversation's currNode to point to the new message.
|
|
||||||
*
|
|
||||||
* @param message - Message to add (without id)
|
|
||||||
* @param parentId - Parent message ID to attach to
|
|
||||||
* @returns The created message
|
|
||||||
*/
|
|
||||||
static async createMessageBranch(
|
|
||||||
message: Omit<DatabaseMessage, 'id'>,
|
|
||||||
parentId: string | null
|
|
||||||
): Promise<DatabaseMessage> {
|
|
||||||
return await db.transaction('rw', [db.conversations, db.messages], async () => {
|
|
||||||
// Handle null parent (root message case)
|
|
||||||
if (parentId !== null) {
|
|
||||||
const parentMessage = await db.messages.get(parentId);
|
|
||||||
if (!parentMessage) {
|
|
||||||
throw new Error(`Parent message ${parentId} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMessage: DatabaseMessage = {
|
|
||||||
...message,
|
|
||||||
id: uuid(),
|
|
||||||
parent: parentId,
|
|
||||||
toolCalls: message.toolCalls ?? '',
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.messages.add(newMessage);
|
|
||||||
|
|
||||||
// Update parent's children array if parent exists
|
|
||||||
if (parentId !== null) {
|
|
||||||
const parentMessage = await db.messages.get(parentId);
|
|
||||||
if (parentMessage) {
|
|
||||||
await db.messages.update(parentId, {
|
|
||||||
children: [...parentMessage.children, newMessage.id]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.updateConversation(message.convId, {
|
|
||||||
currNode: newMessage.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return newMessage;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a root message for a new conversation.
|
|
||||||
* Root messages are not displayed but serve as the tree root for branching.
|
|
||||||
*
|
|
||||||
* @param convId - Conversation ID
|
|
||||||
* @returns The created root message
|
|
||||||
*/
|
|
||||||
static async createRootMessage(convId: string): Promise<string> {
|
|
||||||
const rootMessage: DatabaseMessage = {
|
|
||||||
id: uuid(),
|
|
||||||
convId,
|
|
||||||
type: 'root',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
role: 'system',
|
|
||||||
content: '',
|
|
||||||
parent: null,
|
|
||||||
thinking: '',
|
|
||||||
toolCalls: '',
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.messages.add(rootMessage);
|
|
||||||
return rootMessage.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a system prompt message for a conversation.
|
|
||||||
*
|
|
||||||
* @param convId - Conversation ID
|
|
||||||
* @param systemPrompt - The system prompt content (must be non-empty)
|
|
||||||
* @param parentId - Parent message ID (typically the root message)
|
|
||||||
* @returns The created system message
|
|
||||||
* @throws Error if systemPrompt is empty
|
|
||||||
*/
|
|
||||||
static async createSystemMessage(
|
|
||||||
convId: string,
|
|
||||||
systemPrompt: string,
|
|
||||||
parentId: string
|
|
||||||
): Promise<DatabaseMessage> {
|
|
||||||
const trimmedPrompt = systemPrompt.trim();
|
|
||||||
if (!trimmedPrompt) {
|
|
||||||
throw new Error('Cannot create system message with empty content');
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemMessage: DatabaseMessage = {
|
|
||||||
id: uuid(),
|
|
||||||
convId,
|
|
||||||
type: 'system',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
role: 'system',
|
|
||||||
content: trimmedPrompt,
|
|
||||||
parent: parentId,
|
|
||||||
thinking: '',
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.messages.add(systemMessage);
|
|
||||||
|
|
||||||
const parentMessage = await db.messages.get(parentId);
|
|
||||||
if (parentMessage) {
|
|
||||||
await db.messages.update(parentId, {
|
|
||||||
children: [...parentMessage.children, systemMessage.id]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return systemMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a conversation and all its messages.
|
|
||||||
*
|
|
||||||
* @param id - Conversation ID
|
|
||||||
*/
|
|
||||||
static async deleteConversation(id: string): Promise<void> {
|
|
||||||
await db.transaction('rw', [db.conversations, db.messages], async () => {
|
|
||||||
await db.conversations.delete(id);
|
|
||||||
await db.messages.where('convId').equals(id).delete();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a message and removes it from its parent's children array.
|
|
||||||
*
|
|
||||||
* @param messageId - ID of the message to delete
|
|
||||||
*/
|
|
||||||
static async deleteMessage(messageId: string): Promise<void> {
|
|
||||||
await db.transaction('rw', db.messages, async () => {
|
|
||||||
const message = await db.messages.get(messageId);
|
|
||||||
if (!message) return;
|
|
||||||
|
|
||||||
// Remove this message from its parent's children array
|
|
||||||
if (message.parent) {
|
|
||||||
const parent = await db.messages.get(message.parent);
|
|
||||||
if (parent) {
|
|
||||||
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
|
||||||
await db.messages.put(parent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the message
|
|
||||||
await db.messages.delete(messageId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a message and all its descendant messages (cascading deletion).
|
|
||||||
* This removes the entire branch starting from the specified message.
|
|
||||||
*
|
|
||||||
* @param conversationId - ID of the conversation containing the message
|
|
||||||
* @param messageId - ID of the root message to delete (along with all descendants)
|
|
||||||
* @returns Array of all deleted message IDs
|
|
||||||
*/
|
|
||||||
static async deleteMessageCascading(
|
|
||||||
conversationId: string,
|
|
||||||
messageId: string
|
|
||||||
): Promise<string[]> {
|
|
||||||
return await db.transaction('rw', db.messages, async () => {
|
|
||||||
// Get all messages in the conversation to find descendants
|
|
||||||
const allMessages = await db.messages.where('convId').equals(conversationId).toArray();
|
|
||||||
|
|
||||||
// Find all descendant messages
|
|
||||||
const descendants = findDescendantMessages(allMessages, messageId);
|
|
||||||
const allToDelete = [messageId, ...descendants];
|
|
||||||
|
|
||||||
// Get the message to delete for parent cleanup
|
|
||||||
const message = await db.messages.get(messageId);
|
|
||||||
if (message && message.parent) {
|
|
||||||
const parent = await db.messages.get(message.parent);
|
|
||||||
if (parent) {
|
|
||||||
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
|
||||||
await db.messages.put(parent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all messages in the branch
|
|
||||||
await db.messages.bulkDelete(allToDelete);
|
|
||||||
|
|
||||||
return allToDelete;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all conversations, sorted by last modified time (newest first).
|
|
||||||
*
|
|
||||||
* @returns Array of conversations
|
|
||||||
*/
|
|
||||||
static async getAllConversations(): Promise<DatabaseConversation[]> {
|
|
||||||
return await db.conversations.orderBy('lastModified').reverse().toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a conversation by ID.
|
|
||||||
*
|
|
||||||
* @param id - Conversation ID
|
|
||||||
* @returns The conversation if found, otherwise undefined
|
|
||||||
*/
|
|
||||||
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
|
|
||||||
return await db.conversations.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all messages in a conversation, sorted by timestamp (oldest first).
|
|
||||||
*
|
|
||||||
* @param convId - Conversation ID
|
|
||||||
* @returns Array of messages in the conversation
|
|
||||||
*/
|
|
||||||
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
|
||||||
return await db.messages.where('convId').equals(convId).sortBy('timestamp');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a conversation.
|
|
||||||
*
|
|
||||||
* @param id - Conversation ID
|
|
||||||
* @param updates - Partial updates to apply
|
|
||||||
* @returns Promise that resolves when the conversation is updated
|
|
||||||
*/
|
|
||||||
static async updateConversation(
|
|
||||||
id: string,
|
|
||||||
updates: Partial<Omit<DatabaseConversation, 'id'>>
|
|
||||||
): Promise<void> {
|
|
||||||
await db.conversations.update(id, {
|
|
||||||
...updates,
|
|
||||||
lastModified: Date.now()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Navigation
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the conversation's current node (active branch).
|
|
||||||
* This determines which conversation path is currently being viewed.
|
|
||||||
*
|
|
||||||
* @param convId - Conversation ID
|
|
||||||
* @param nodeId - Message ID to set as current node
|
|
||||||
*/
|
|
||||||
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
|
|
||||||
await this.updateConversation(convId, {
|
|
||||||
currNode: nodeId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a message.
|
|
||||||
*
|
|
||||||
* @param id - Message ID
|
|
||||||
* @param updates - Partial updates to apply
|
|
||||||
* @returns Promise that resolves when the message is updated
|
|
||||||
*/
|
|
||||||
static async updateMessage(
|
|
||||||
id: string,
|
|
||||||
updates: Partial<Omit<DatabaseMessage, 'id'>>
|
|
||||||
): Promise<void> {
|
|
||||||
await db.messages.update(id, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Import
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports multiple conversations and their messages.
|
|
||||||
* Skips conversations that already exist.
|
|
||||||
*
|
|
||||||
* @param data - Array of { conv, messages } objects
|
|
||||||
*/
|
|
||||||
static async importConversations(
|
|
||||||
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
|
|
||||||
): Promise<{ imported: number; skipped: number }> {
|
|
||||||
let importedCount = 0;
|
|
||||||
let skippedCount = 0;
|
|
||||||
|
|
||||||
return await db.transaction('rw', [db.conversations, db.messages], async () => {
|
|
||||||
for (const item of data) {
|
|
||||||
const { conv, messages } = item;
|
|
||||||
|
|
||||||
const existing = await db.conversations.get(conv.id);
|
|
||||||
if (existing) {
|
|
||||||
console.warn(`Conversation "${conv.name}" already exists, skipping...`);
|
|
||||||
skippedCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.conversations.add(conv);
|
|
||||||
for (const msg of messages) {
|
|
||||||
await db.messages.put(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
importedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { imported: importedCount, skipped: skippedCount };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export { ChatService } from './chat';
|
export { ChatService } from './chat.service';
|
||||||
export { DatabaseService } from './database';
|
export { DatabaseService } from './database.service';
|
||||||
export { ModelsService } from './models';
|
export { ModelsService } from './models.service';
|
||||||
export { PropsService } from './props';
|
export { PropsService } from './props.service';
|
||||||
export { ParameterSyncService } from './parameter-sync';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
|
import { base } from '$app/paths';
|
||||||
import { ServerModelStatus } from '$lib/enums';
|
import { ServerModelStatus } from '$lib/enums';
|
||||||
import { apiFetch, apiPost } from '$lib/utils/api-fetch';
|
import { getJsonHeaders } from '$lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelsService - Stateless service for model management API communication
|
||||||
|
*
|
||||||
|
* This service handles communication with model-related endpoints:
|
||||||
|
* - `/v1/models` - OpenAI-compatible model list (MODEL + ROUTER mode)
|
||||||
|
* - `/models/load`, `/models/unload` - Router-specific model management (ROUTER mode only)
|
||||||
|
*
|
||||||
|
* **Responsibilities:**
|
||||||
|
* - List available models
|
||||||
|
* - Load/unload models (ROUTER mode)
|
||||||
|
* - Check model status (ROUTER mode)
|
||||||
|
*
|
||||||
|
* **Used by:**
|
||||||
|
* - modelsStore: Primary consumer for model state management
|
||||||
|
*/
|
||||||
export class ModelsService {
|
export class ModelsService {
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|
@ -11,24 +27,35 @@ export class ModelsService {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch list of models from OpenAI-compatible endpoint.
|
* Fetch list of models from OpenAI-compatible endpoint
|
||||||
* Works in both MODEL and ROUTER modes.
|
* Works in both MODEL and ROUTER modes
|
||||||
*
|
|
||||||
* @returns List of available models with basic metadata
|
|
||||||
*/
|
*/
|
||||||
static async list(): Promise<ApiModelListResponse> {
|
static async list(): Promise<ApiModelListResponse> {
|
||||||
return apiFetch<ApiModelListResponse>('/v1/models');
|
const response = await fetch(`${base}/v1/models`, {
|
||||||
|
headers: getJsonHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch model list (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ApiModelListResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch list of all models with detailed metadata (ROUTER mode).
|
* Fetch list of all models with detailed metadata (ROUTER mode)
|
||||||
* Returns models with load status, paths, and other metadata
|
* Returns models with load status, paths, and other metadata
|
||||||
* beyond what the OpenAI-compatible endpoint provides.
|
|
||||||
*
|
|
||||||
* @returns List of models with detailed status and configuration info
|
|
||||||
*/
|
*/
|
||||||
static async listRouter(): Promise<ApiRouterModelsListResponse> {
|
static async listRouter(): Promise<ApiRouterModelsListResponse> {
|
||||||
return apiFetch<ApiRouterModelsListResponse>('/v1/models');
|
const response = await fetch(`${base}/v1/models`, {
|
||||||
|
headers: getJsonHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch router models list (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ApiRouterModelsListResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,13 +67,10 @@ export class ModelsService {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a model (ROUTER mode only).
|
* Load a model (ROUTER mode)
|
||||||
* Sends POST request to `/models/load`. Note: the endpoint returns success
|
* POST /models/load
|
||||||
* before loading completes — use polling to await actual load status.
|
|
||||||
*
|
|
||||||
* @param modelId - Model identifier to load
|
* @param modelId - Model identifier to load
|
||||||
* @param extraArgs - Optional additional arguments to pass to the model instance
|
* @param extraArgs - Optional additional arguments to pass to the model instance
|
||||||
* @returns Load response from the server
|
|
||||||
*/
|
*/
|
||||||
static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
|
static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
|
||||||
const payload: { model: string; extra_args?: string[] } = { model: modelId };
|
const payload: { model: string; extra_args?: string[] } = { model: modelId };
|
||||||
|
|
@ -54,19 +78,38 @@ export class ModelsService {
|
||||||
payload.extra_args = extraArgs;
|
payload.extra_args = extraArgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiPost<ApiRouterModelsLoadResponse>('/models/load', payload);
|
const response = await fetch(`${base}/models/load`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getJsonHeaders(),
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `Failed to load model (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ApiRouterModelsLoadResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unload a model (ROUTER mode only).
|
* Unload a model (ROUTER mode)
|
||||||
* Sends POST request to `/models/unload`. Note: the endpoint returns success
|
* POST /models/unload
|
||||||
* before unloading completes — use polling to await actual unload status.
|
|
||||||
*
|
|
||||||
* @param modelId - Model identifier to unload
|
* @param modelId - Model identifier to unload
|
||||||
* @returns Unload response from the server
|
|
||||||
*/
|
*/
|
||||||
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
|
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
|
||||||
return apiPost<ApiRouterModelsUnloadResponse>('/models/unload', { model: modelId });
|
const response = await fetch(`${base}/models/unload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getJsonHeaders(),
|
||||||
|
body: JSON.stringify({ model: modelId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `Failed to unload model (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ApiRouterModelsUnloadResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -78,20 +121,14 @@ export class ModelsService {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a model is loaded based on its metadata.
|
* Check if a model is loaded based on its metadata
|
||||||
*
|
|
||||||
* @param model - Model data entry from the API response
|
|
||||||
* @returns True if the model status is LOADED
|
|
||||||
*/
|
*/
|
||||||
static isModelLoaded(model: ApiModelDataEntry): boolean {
|
static isModelLoaded(model: ApiModelDataEntry): boolean {
|
||||||
return model.status.value === ServerModelStatus.LOADED;
|
return model.status.value === ServerModelStatus.LOADED;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a model is currently loading.
|
* Check if a model is currently loading
|
||||||
*
|
|
||||||
* @param model - Model data entry from the API response
|
|
||||||
* @returns True if the model status is LOADING
|
|
||||||
*/
|
*/
|
||||||
static isModelLoading(model: ApiModelDataEntry): boolean {
|
static isModelLoading(model: ApiModelDataEntry): boolean {
|
||||||
return model.status.value === ServerModelStatus.LOADING;
|
return model.status.value === ServerModelStatus.LOADING;
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import { base } from '$app/paths';
|
|
||||||
import { ServerModelStatus } from '$lib/enums';
|
|
||||||
import { getJsonHeaders } from '$lib/utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ModelsService - Stateless service for model management API communication
|
|
||||||
*
|
|
||||||
* This service handles communication with model-related endpoints:
|
|
||||||
* - `/v1/models` - OpenAI-compatible model list (MODEL + ROUTER mode)
|
|
||||||
* - `/models/load`, `/models/unload` - Router-specific model management (ROUTER mode only)
|
|
||||||
*
|
|
||||||
* **Responsibilities:**
|
|
||||||
* - List available models
|
|
||||||
* - Load/unload models (ROUTER mode)
|
|
||||||
* - Check model status (ROUTER mode)
|
|
||||||
*
|
|
||||||
* **Used by:**
|
|
||||||
* - modelsStore: Primary consumer for model state management
|
|
||||||
*/
|
|
||||||
export class ModelsService {
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Listing
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch list of models from OpenAI-compatible endpoint
|
|
||||||
* Works in both MODEL and ROUTER modes
|
|
||||||
*/
|
|
||||||
static async list(): Promise<ApiModelListResponse> {
|
|
||||||
const response = await fetch(`${base}/v1/models`, {
|
|
||||||
headers: getJsonHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch model list (status ${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<ApiModelListResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch list of all models with detailed metadata (ROUTER mode)
|
|
||||||
* Returns models with load status, paths, and other metadata
|
|
||||||
*/
|
|
||||||
static async listRouter(): Promise<ApiRouterModelsListResponse> {
|
|
||||||
const response = await fetch(`${base}/v1/models`, {
|
|
||||||
headers: getJsonHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch router models list (status ${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<ApiRouterModelsListResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Load/Unload
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a model (ROUTER mode)
|
|
||||||
* POST /models/load
|
|
||||||
* @param modelId - Model identifier to load
|
|
||||||
* @param extraArgs - Optional additional arguments to pass to the model instance
|
|
||||||
*/
|
|
||||||
static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
|
|
||||||
const payload: { model: string; extra_args?: string[] } = { model: modelId };
|
|
||||||
if (extraArgs && extraArgs.length > 0) {
|
|
||||||
payload.extra_args = extraArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${base}/models/load`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getJsonHeaders(),
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.error || `Failed to load model (status ${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<ApiRouterModelsLoadResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unload a model (ROUTER mode)
|
|
||||||
* POST /models/unload
|
|
||||||
* @param modelId - Model identifier to unload
|
|
||||||
*/
|
|
||||||
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
|
|
||||||
const response = await fetch(`${base}/models/unload`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getJsonHeaders(),
|
|
||||||
body: JSON.stringify({ model: modelId })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.error || `Failed to unload model (status ${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<ApiRouterModelsUnloadResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Status
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a model is loaded based on its metadata
|
|
||||||
*/
|
|
||||||
static isModelLoaded(model: ApiModelDataEntry): boolean {
|
|
||||||
return model.status.value === ServerModelStatus.LOADED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a model is currently loading
|
|
||||||
*/
|
|
||||||
static isModelLoading(model: ApiModelDataEntry): boolean {
|
|
||||||
return model.status.value === ServerModelStatus.LOADING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +1,100 @@
|
||||||
|
/**
|
||||||
|
* ParameterSyncService - Handles synchronization between server defaults and user settings
|
||||||
|
*
|
||||||
|
* This service manages the complex logic of merging server-provided default parameters
|
||||||
|
* with user-configured overrides, ensuring the UI reflects the actual server state
|
||||||
|
* while preserving user customizations.
|
||||||
|
*
|
||||||
|
* **Key Responsibilities:**
|
||||||
|
* - Extract syncable parameters from server props
|
||||||
|
* - Merge server defaults with user overrides
|
||||||
|
* - Track parameter sources (server, user, default)
|
||||||
|
* - Provide sync utilities for settings store integration
|
||||||
|
*/
|
||||||
|
|
||||||
import { normalizeFloatingPoint } from '$lib/utils';
|
import { normalizeFloatingPoint } from '$lib/utils';
|
||||||
import { SyncableParameterType, ParameterSource } from '$lib/enums/settings';
|
|
||||||
|
|
||||||
type ParameterValue = string | number | boolean;
|
export type ParameterSource = 'default' | 'custom';
|
||||||
type ParameterRecord = Record<string, ParameterValue>;
|
export type ParameterValue = string | number | boolean;
|
||||||
|
export type ParameterRecord = Record<string, ParameterValue>;
|
||||||
|
|
||||||
interface ParameterInfo {
|
export interface ParameterInfo {
|
||||||
value: string | number | boolean;
|
value: string | number | boolean;
|
||||||
source: ParameterSource;
|
source: ParameterSource;
|
||||||
serverDefault?: string | number | boolean;
|
serverDefault?: string | number | boolean;
|
||||||
userOverride?: string | number | boolean;
|
userOverride?: string | number | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncableParameter {
|
export interface SyncableParameter {
|
||||||
key: string;
|
key: string;
|
||||||
serverKey: string;
|
serverKey: string;
|
||||||
type: SyncableParameterType;
|
type: 'number' | 'string' | 'boolean';
|
||||||
canSync: boolean;
|
canSync: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping of webui setting keys to server parameter keys.
|
* Mapping of webui setting keys to server parameter keys
|
||||||
* Only parameters listed here can be synced from the server `/props` endpoint.
|
* Only parameters that should be synced from server are included
|
||||||
* Each entry defines the webui key, corresponding server key, value type,
|
|
||||||
* and whether sync is enabled.
|
|
||||||
*/
|
*/
|
||||||
export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
|
export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
|
||||||
{
|
{ key: 'temperature', serverKey: 'temperature', type: 'number', canSync: true },
|
||||||
key: 'temperature',
|
{ key: 'top_k', serverKey: 'top_k', type: 'number', canSync: true },
|
||||||
serverKey: 'temperature',
|
{ key: 'top_p', serverKey: 'top_p', type: 'number', canSync: true },
|
||||||
type: SyncableParameterType.NUMBER,
|
{ key: 'min_p', serverKey: 'min_p', type: 'number', canSync: true },
|
||||||
canSync: true
|
{ key: 'dynatemp_range', serverKey: 'dynatemp_range', type: 'number', canSync: true },
|
||||||
},
|
{ key: 'dynatemp_exponent', serverKey: 'dynatemp_exponent', type: 'number', canSync: true },
|
||||||
{ key: 'top_k', serverKey: 'top_k', type: SyncableParameterType.NUMBER, canSync: true },
|
{ key: 'xtc_probability', serverKey: 'xtc_probability', type: 'number', canSync: true },
|
||||||
{ key: 'top_p', serverKey: 'top_p', type: SyncableParameterType.NUMBER, canSync: true },
|
{ key: 'xtc_threshold', serverKey: 'xtc_threshold', type: 'number', canSync: true },
|
||||||
{ key: 'min_p', serverKey: 'min_p', type: SyncableParameterType.NUMBER, canSync: true },
|
{ key: 'typ_p', serverKey: 'typ_p', type: 'number', canSync: true },
|
||||||
{
|
{ key: 'repeat_last_n', serverKey: 'repeat_last_n', type: 'number', canSync: true },
|
||||||
key: 'dynatemp_range',
|
{ key: 'repeat_penalty', serverKey: 'repeat_penalty', type: 'number', canSync: true },
|
||||||
serverKey: 'dynatemp_range',
|
{ key: 'presence_penalty', serverKey: 'presence_penalty', type: 'number', canSync: true },
|
||||||
type: SyncableParameterType.NUMBER,
|
{ key: 'frequency_penalty', serverKey: 'frequency_penalty', type: 'number', canSync: true },
|
||||||
canSync: true
|
{ key: 'dry_multiplier', serverKey: 'dry_multiplier', type: 'number', canSync: true },
|
||||||
},
|
{ key: 'dry_base', serverKey: 'dry_base', type: 'number', canSync: true },
|
||||||
{
|
{ key: 'dry_allowed_length', serverKey: 'dry_allowed_length', type: 'number', canSync: true },
|
||||||
key: 'dynatemp_exponent',
|
{ key: 'dry_penalty_last_n', serverKey: 'dry_penalty_last_n', type: 'number', canSync: true },
|
||||||
serverKey: 'dynatemp_exponent',
|
{ key: 'max_tokens', serverKey: 'max_tokens', type: 'number', canSync: true },
|
||||||
type: SyncableParameterType.NUMBER,
|
{ key: 'samplers', serverKey: 'samplers', type: 'string', canSync: true },
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'xtc_probability',
|
|
||||||
serverKey: 'xtc_probability',
|
|
||||||
type: SyncableParameterType.NUMBER,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'xtc_threshold',
|
|
||||||
serverKey: 'xtc_threshold',
|
|
||||||
type: SyncableParameterType.NUMBER,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{ key: 'typ_p', serverKey: 'typ_p', type: SyncableParameterType.NUMBER, canSync: true },
|
|
||||||
{
|
|
||||||
key: 'repeat_last_n',
|
|
||||||
serverKey: 'repeat_last_n',
|
|
||||||
type: SyncableParameterType.NUMBER,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'repeat_penalty',
|
|
||||||
serverKey: 'repeat_penalty',
|
|
||||||
type: SyncableParameterType.NUMBER,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'presence_penalty',
|
|
||||||
serverKey: 'presence_penalty',
|
|
||||||
type: SyncableParameterType.NUMBER,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'frequency_penalty',
|
|
||||||
serverKey: 'frequency_penalty',
|
|
||||||
type: SyncableParameterType.NUMBER,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'dry_multiplier',
|
|
||||||
serverKey: 'dry_multiplier',
|
|
||||||
type: SyncableParameterType.NUMBER,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{ key: 'dry_base', serverKey: 'dry_base', type: SyncableParameterType.NUMBER, canSync: true },
|
|
||||||
{
|
|
||||||
key: 'dry_allowed_length',
|
|
||||||
serverKey: 'dry_allowed_length',
|
|
||||||
type: SyncableParameterType.NUMBER,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'dry_penalty_last_n',
|
|
||||||
serverKey: 'dry_penalty_last_n',
|
|
||||||
type: SyncableParameterType.NUMBER,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{ key: 'max_tokens', serverKey: 'max_tokens', type: SyncableParameterType.NUMBER, canSync: true },
|
|
||||||
{ key: 'samplers', serverKey: 'samplers', type: SyncableParameterType.STRING, canSync: true },
|
|
||||||
{
|
{
|
||||||
key: 'pasteLongTextToFileLen',
|
key: 'pasteLongTextToFileLen',
|
||||||
serverKey: 'pasteLongTextToFileLen',
|
serverKey: 'pasteLongTextToFileLen',
|
||||||
type: SyncableParameterType.NUMBER,
|
type: 'number',
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'pdfAsImage',
|
|
||||||
serverKey: 'pdfAsImage',
|
|
||||||
type: SyncableParameterType.BOOLEAN,
|
|
||||||
canSync: true
|
canSync: true
|
||||||
},
|
},
|
||||||
|
{ key: 'pdfAsImage', serverKey: 'pdfAsImage', type: 'boolean', canSync: true },
|
||||||
{
|
{
|
||||||
key: 'showThoughtInProgress',
|
key: 'showThoughtInProgress',
|
||||||
serverKey: 'showThoughtInProgress',
|
serverKey: 'showThoughtInProgress',
|
||||||
type: SyncableParameterType.BOOLEAN,
|
type: 'boolean',
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'keepStatsVisible',
|
|
||||||
serverKey: 'keepStatsVisible',
|
|
||||||
type: SyncableParameterType.BOOLEAN,
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'showMessageStats',
|
|
||||||
serverKey: 'showMessageStats',
|
|
||||||
type: SyncableParameterType.BOOLEAN,
|
|
||||||
canSync: true
|
canSync: true
|
||||||
},
|
},
|
||||||
|
{ key: 'keepStatsVisible', serverKey: 'keepStatsVisible', type: 'boolean', canSync: true },
|
||||||
|
{ key: 'showMessageStats', serverKey: 'showMessageStats', type: 'boolean', canSync: true },
|
||||||
{
|
{
|
||||||
key: 'askForTitleConfirmation',
|
key: 'askForTitleConfirmation',
|
||||||
serverKey: 'askForTitleConfirmation',
|
serverKey: 'askForTitleConfirmation',
|
||||||
type: SyncableParameterType.BOOLEAN,
|
type: 'boolean',
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'disableAutoScroll',
|
|
||||||
serverKey: 'disableAutoScroll',
|
|
||||||
type: SyncableParameterType.BOOLEAN,
|
|
||||||
canSync: true
|
canSync: true
|
||||||
},
|
},
|
||||||
|
{ key: 'disableAutoScroll', serverKey: 'disableAutoScroll', type: 'boolean', canSync: true },
|
||||||
{
|
{
|
||||||
key: 'renderUserContentAsMarkdown',
|
key: 'renderUserContentAsMarkdown',
|
||||||
serverKey: 'renderUserContentAsMarkdown',
|
serverKey: 'renderUserContentAsMarkdown',
|
||||||
type: SyncableParameterType.BOOLEAN,
|
type: 'boolean',
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'autoMicOnEmpty',
|
|
||||||
serverKey: 'autoMicOnEmpty',
|
|
||||||
type: SyncableParameterType.BOOLEAN,
|
|
||||||
canSync: true
|
canSync: true
|
||||||
},
|
},
|
||||||
|
{ key: 'autoMicOnEmpty', serverKey: 'autoMicOnEmpty', type: 'boolean', canSync: true },
|
||||||
{
|
{
|
||||||
key: 'pyInterpreterEnabled',
|
key: 'pyInterpreterEnabled',
|
||||||
serverKey: 'pyInterpreterEnabled',
|
serverKey: 'pyInterpreterEnabled',
|
||||||
type: SyncableParameterType.BOOLEAN,
|
type: 'boolean',
|
||||||
canSync: true
|
canSync: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'enableContinueGeneration',
|
key: 'enableContinueGeneration',
|
||||||
serverKey: 'enableContinueGeneration',
|
serverKey: 'enableContinueGeneration',
|
||||||
type: SyncableParameterType.BOOLEAN,
|
type: 'boolean',
|
||||||
canSync: true
|
canSync: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
@ -182,24 +109,14 @@ export class ParameterSyncService {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Round floating-point numbers to avoid JavaScript precision issues.
|
* Round floating-point numbers to avoid JavaScript precision issues
|
||||||
* E.g., 0.1 + 0.2 = 0.30000000000000004 → 0.3
|
|
||||||
*
|
|
||||||
* @param value - Parameter value to normalize
|
|
||||||
* @returns Precision-normalized value
|
|
||||||
*/
|
*/
|
||||||
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
|
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
|
||||||
return normalizeFloatingPoint(value) as ParameterValue;
|
return normalizeFloatingPoint(value) as ParameterValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract server default parameters that can be synced from `/props` response.
|
* Extract server default parameters that can be synced
|
||||||
* Handles both generation settings parameters and webui-specific settings.
|
|
||||||
* Converts samplers array to semicolon-delimited string for UI display.
|
|
||||||
*
|
|
||||||
* @param serverParams - Raw generation settings from server `/props` endpoint
|
|
||||||
* @param webuiSettings - Optional webui-specific settings from server
|
|
||||||
* @returns Record of extracted parameter key-value pairs with normalized precision
|
|
||||||
*/
|
*/
|
||||||
static extractServerDefaults(
|
static extractServerDefaults(
|
||||||
serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
|
serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
|
||||||
|
|
@ -249,14 +166,8 @@ export class ParameterSyncService {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge server defaults with current user settings.
|
* Merge server defaults with current user settings
|
||||||
* User overrides always take priority — only parameters not in `userOverrides`
|
* Returns updated settings that respect user overrides while using server defaults
|
||||||
* set will be updated from server defaults.
|
|
||||||
*
|
|
||||||
* @param currentSettings - Current parameter values in the settings store
|
|
||||||
* @param serverDefaults - Default values extracted from server props
|
|
||||||
* @param userOverrides - Set of parameter keys explicitly overridden by the user
|
|
||||||
* @returns Merged parameter record with user overrides preserved
|
|
||||||
*/
|
*/
|
||||||
static mergeWithServerDefaults(
|
static mergeWithServerDefaults(
|
||||||
currentSettings: ParameterRecord,
|
currentSettings: ParameterRecord,
|
||||||
|
|
@ -284,15 +195,7 @@ export class ParameterSyncService {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get parameter information including source and values.
|
* Get parameter information including source and values
|
||||||
* Used by ChatSettingsParameterSourceIndicator to display the correct badge
|
|
||||||
* (Custom vs Default) for each parameter in the settings UI.
|
|
||||||
*
|
|
||||||
* @param key - The parameter key to get info for
|
|
||||||
* @param currentValue - The current value of the parameter
|
|
||||||
* @param propsDefaults - Server default values from `/props`
|
|
||||||
* @param userOverrides - Set of parameter keys explicitly overridden by the user
|
|
||||||
* @returns Parameter info with source, server default, and user override values
|
|
||||||
*/
|
*/
|
||||||
static getParameterInfo(
|
static getParameterInfo(
|
||||||
key: string,
|
key: string,
|
||||||
|
|
@ -304,7 +207,7 @@ export class ParameterSyncService {
|
||||||
const isUserOverride = userOverrides.has(key);
|
const isUserOverride = userOverrides.has(key);
|
||||||
|
|
||||||
// Simple logic: either using default (from props) or custom (user override)
|
// Simple logic: either using default (from props) or custom (user override)
|
||||||
const source = isUserOverride ? ParameterSource.CUSTOM : ParameterSource.DEFAULT;
|
const source: ParameterSource = isUserOverride ? 'custom' : 'default';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value: currentValue,
|
value: currentValue,
|
||||||
|
|
@ -315,41 +218,32 @@ export class ParameterSyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a parameter can be synced from server.
|
* Check if a parameter can be synced from server
|
||||||
*
|
|
||||||
* @param key - The parameter key to check
|
|
||||||
* @returns True if the parameter is in the syncable parameters list
|
|
||||||
*/
|
*/
|
||||||
static canSyncParameter(key: string): boolean {
|
static canSyncParameter(key: string): boolean {
|
||||||
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
|
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all syncable parameter keys.
|
* Get all syncable parameter keys
|
||||||
*
|
|
||||||
* @returns Array of parameter keys that can be synced from server
|
|
||||||
*/
|
*/
|
||||||
static getSyncableParameterKeys(): string[] {
|
static getSyncableParameterKeys(): string[] {
|
||||||
return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
|
return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a server parameter value against its expected type.
|
* Validate server parameter value
|
||||||
*
|
|
||||||
* @param key - The parameter key to validate
|
|
||||||
* @param value - The value to validate
|
|
||||||
* @returns True if value matches the expected type for this parameter
|
|
||||||
*/
|
*/
|
||||||
static validateServerParameter(key: string, value: ParameterValue): boolean {
|
static validateServerParameter(key: string, value: ParameterValue): boolean {
|
||||||
const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
|
const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
|
||||||
if (!param) return false;
|
if (!param) return false;
|
||||||
|
|
||||||
switch (param.type) {
|
switch (param.type) {
|
||||||
case SyncableParameterType.NUMBER:
|
case 'number':
|
||||||
return typeof value === 'number' && !isNaN(value);
|
return typeof value === 'number' && !isNaN(value);
|
||||||
case SyncableParameterType.STRING:
|
case 'string':
|
||||||
return typeof value === 'string';
|
return typeof value === 'string';
|
||||||
case SyncableParameterType.BOOLEAN:
|
case 'boolean':
|
||||||
return typeof value === 'boolean';
|
return typeof value === 'boolean';
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -365,13 +259,7 @@ export class ParameterSyncService {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a diff between current settings and server defaults.
|
* Create a diff between current settings and server defaults
|
||||||
* Shows which parameters differ from server values, useful for debugging
|
|
||||||
* and for the "Reset to defaults" functionality.
|
|
||||||
*
|
|
||||||
* @param currentSettings - Current parameter values in the settings store
|
|
||||||
* @param serverDefaults - Default values extracted from server props
|
|
||||||
* @returns Record of parameter diffs with current value, server value, and whether they differ
|
|
||||||
*/
|
*/
|
||||||
static createParameterDiff(
|
static createParameterDiff(
|
||||||
currentSettings: ParameterRecord,
|
currentSettings: ParameterRecord,
|
||||||
|
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { ParameterSyncService } from './parameter-sync';
|
|
||||||
|
|
||||||
describe('ParameterSyncService', () => {
|
|
||||||
describe('roundFloatingPoint', () => {
|
|
||||||
it('should fix JavaScript floating-point precision issues', () => {
|
|
||||||
// Test the specific values from the screenshot
|
|
||||||
const mockServerParams = {
|
|
||||||
top_p: 0.949999988079071,
|
|
||||||
min_p: 0.009999999776482582,
|
|
||||||
temperature: 0.800000011920929,
|
|
||||||
top_k: 40,
|
|
||||||
samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature']
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = ParameterSyncService.extractServerDefaults({
|
|
||||||
...mockServerParams,
|
|
||||||
// Add other required fields to match the API type
|
|
||||||
n_predict: 512,
|
|
||||||
seed: -1,
|
|
||||||
dynatemp_range: 0.0,
|
|
||||||
dynatemp_exponent: 1.0,
|
|
||||||
xtc_probability: 0.0,
|
|
||||||
xtc_threshold: 0.1,
|
|
||||||
typ_p: 1.0,
|
|
||||||
repeat_last_n: 64,
|
|
||||||
repeat_penalty: 1.0,
|
|
||||||
presence_penalty: 0.0,
|
|
||||||
frequency_penalty: 0.0,
|
|
||||||
dry_multiplier: 0.0,
|
|
||||||
dry_base: 1.75,
|
|
||||||
dry_allowed_length: 2,
|
|
||||||
dry_penalty_last_n: -1,
|
|
||||||
mirostat: 0,
|
|
||||||
mirostat_tau: 5.0,
|
|
||||||
mirostat_eta: 0.1,
|
|
||||||
stop: [],
|
|
||||||
max_tokens: -1,
|
|
||||||
n_keep: 0,
|
|
||||||
n_discard: 0,
|
|
||||||
ignore_eos: false,
|
|
||||||
stream: true,
|
|
||||||
logit_bias: [],
|
|
||||||
n_probs: 0,
|
|
||||||
min_keep: 0,
|
|
||||||
grammar: '',
|
|
||||||
grammar_lazy: false,
|
|
||||||
grammar_triggers: [],
|
|
||||||
preserved_tokens: [],
|
|
||||||
chat_format: '',
|
|
||||||
reasoning_format: '',
|
|
||||||
reasoning_in_content: false,
|
|
||||||
thinking_forced_open: false,
|
|
||||||
'speculative.n_max': 0,
|
|
||||||
'speculative.n_min': 0,
|
|
||||||
'speculative.p_min': 0.0,
|
|
||||||
timings_per_token: false,
|
|
||||||
post_sampling_probs: false,
|
|
||||||
lora: [],
|
|
||||||
top_n_sigma: 0.0,
|
|
||||||
dry_sequence_breakers: []
|
|
||||||
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
|
|
||||||
|
|
||||||
// Check that the problematic floating-point values are rounded correctly
|
|
||||||
expect(result.top_p).toBe(0.95);
|
|
||||||
expect(result.min_p).toBe(0.01);
|
|
||||||
expect(result.temperature).toBe(0.8);
|
|
||||||
expect(result.top_k).toBe(40); // Integer should remain unchanged
|
|
||||||
expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve non-numeric values', () => {
|
|
||||||
const mockServerParams = {
|
|
||||||
samplers: ['top_k', 'temperature'],
|
|
||||||
max_tokens: -1,
|
|
||||||
temperature: 0.7
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = ParameterSyncService.extractServerDefaults({
|
|
||||||
...mockServerParams,
|
|
||||||
// Minimal required fields
|
|
||||||
n_predict: 512,
|
|
||||||
seed: -1,
|
|
||||||
dynatemp_range: 0.0,
|
|
||||||
dynatemp_exponent: 1.0,
|
|
||||||
top_k: 40,
|
|
||||||
top_p: 0.95,
|
|
||||||
min_p: 0.05,
|
|
||||||
xtc_probability: 0.0,
|
|
||||||
xtc_threshold: 0.1,
|
|
||||||
typ_p: 1.0,
|
|
||||||
repeat_last_n: 64,
|
|
||||||
repeat_penalty: 1.0,
|
|
||||||
presence_penalty: 0.0,
|
|
||||||
frequency_penalty: 0.0,
|
|
||||||
dry_multiplier: 0.0,
|
|
||||||
dry_base: 1.75,
|
|
||||||
dry_allowed_length: 2,
|
|
||||||
dry_penalty_last_n: -1,
|
|
||||||
mirostat: 0,
|
|
||||||
mirostat_tau: 5.0,
|
|
||||||
mirostat_eta: 0.1,
|
|
||||||
stop: [],
|
|
||||||
n_keep: 0,
|
|
||||||
n_discard: 0,
|
|
||||||
ignore_eos: false,
|
|
||||||
stream: true,
|
|
||||||
logit_bias: [],
|
|
||||||
n_probs: 0,
|
|
||||||
min_keep: 0,
|
|
||||||
grammar: '',
|
|
||||||
grammar_lazy: false,
|
|
||||||
grammar_triggers: [],
|
|
||||||
preserved_tokens: [],
|
|
||||||
chat_format: '',
|
|
||||||
reasoning_format: '',
|
|
||||||
reasoning_in_content: false,
|
|
||||||
thinking_forced_open: false,
|
|
||||||
'speculative.n_max': 0,
|
|
||||||
'speculative.n_min': 0,
|
|
||||||
'speculative.p_min': 0.0,
|
|
||||||
timings_per_token: false,
|
|
||||||
post_sampling_probs: false,
|
|
||||||
lora: [],
|
|
||||||
top_n_sigma: 0.0,
|
|
||||||
dry_sequence_breakers: []
|
|
||||||
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
|
|
||||||
|
|
||||||
expect(result.samplers).toBe('top_k;temperature');
|
|
||||||
expect(result.max_tokens).toBe(-1);
|
|
||||||
expect(result.temperature).toBe(0.7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge webui settings from props when provided', () => {
|
|
||||||
const result = ParameterSyncService.extractServerDefaults(null, {
|
|
||||||
pasteLongTextToFileLen: 0,
|
|
||||||
pdfAsImage: true,
|
|
||||||
renderUserContentAsMarkdown: false,
|
|
||||||
theme: 'dark'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.pasteLongTextToFileLen).toBe(0);
|
|
||||||
expect(result.pdfAsImage).toBe(true);
|
|
||||||
expect(result.renderUserContentAsMarkdown).toBe(false);
|
|
||||||
expect(result.theme).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
/**
|
|
||||||
* ParameterSyncService - Handles synchronization between server defaults and user settings
|
|
||||||
*
|
|
||||||
* This service manages the complex logic of merging server-provided default parameters
|
|
||||||
* with user-configured overrides, ensuring the UI reflects the actual server state
|
|
||||||
* while preserving user customizations.
|
|
||||||
*
|
|
||||||
* **Key Responsibilities:**
|
|
||||||
* - Extract syncable parameters from server props
|
|
||||||
* - Merge server defaults with user overrides
|
|
||||||
* - Track parameter sources (server, user, default)
|
|
||||||
* - Provide sync utilities for settings store integration
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { normalizeFloatingPoint } from '$lib/utils';
|
|
||||||
|
|
||||||
export type ParameterSource = 'default' | 'custom';
|
|
||||||
export type ParameterValue = string | number | boolean;
|
|
||||||
export type ParameterRecord = Record<string, ParameterValue>;
|
|
||||||
|
|
||||||
export interface ParameterInfo {
|
|
||||||
value: string | number | boolean;
|
|
||||||
source: ParameterSource;
|
|
||||||
serverDefault?: string | number | boolean;
|
|
||||||
userOverride?: string | number | boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SyncableParameter {
|
|
||||||
key: string;
|
|
||||||
serverKey: string;
|
|
||||||
type: 'number' | 'string' | 'boolean';
|
|
||||||
canSync: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping of webui setting keys to server parameter keys
|
|
||||||
* Only parameters that should be synced from server are included
|
|
||||||
*/
|
|
||||||
export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
|
|
||||||
{ key: 'temperature', serverKey: 'temperature', type: 'number', canSync: true },
|
|
||||||
{ key: 'top_k', serverKey: 'top_k', type: 'number', canSync: true },
|
|
||||||
{ key: 'top_p', serverKey: 'top_p', type: 'number', canSync: true },
|
|
||||||
{ key: 'min_p', serverKey: 'min_p', type: 'number', canSync: true },
|
|
||||||
{ key: 'dynatemp_range', serverKey: 'dynatemp_range', type: 'number', canSync: true },
|
|
||||||
{ key: 'dynatemp_exponent', serverKey: 'dynatemp_exponent', type: 'number', canSync: true },
|
|
||||||
{ key: 'xtc_probability', serverKey: 'xtc_probability', type: 'number', canSync: true },
|
|
||||||
{ key: 'xtc_threshold', serverKey: 'xtc_threshold', type: 'number', canSync: true },
|
|
||||||
{ key: 'typ_p', serverKey: 'typ_p', type: 'number', canSync: true },
|
|
||||||
{ key: 'repeat_last_n', serverKey: 'repeat_last_n', type: 'number', canSync: true },
|
|
||||||
{ key: 'repeat_penalty', serverKey: 'repeat_penalty', type: 'number', canSync: true },
|
|
||||||
{ key: 'presence_penalty', serverKey: 'presence_penalty', type: 'number', canSync: true },
|
|
||||||
{ key: 'frequency_penalty', serverKey: 'frequency_penalty', type: 'number', canSync: true },
|
|
||||||
{ key: 'dry_multiplier', serverKey: 'dry_multiplier', type: 'number', canSync: true },
|
|
||||||
{ key: 'dry_base', serverKey: 'dry_base', type: 'number', canSync: true },
|
|
||||||
{ key: 'dry_allowed_length', serverKey: 'dry_allowed_length', type: 'number', canSync: true },
|
|
||||||
{ key: 'dry_penalty_last_n', serverKey: 'dry_penalty_last_n', type: 'number', canSync: true },
|
|
||||||
{ key: 'max_tokens', serverKey: 'max_tokens', type: 'number', canSync: true },
|
|
||||||
{ key: 'samplers', serverKey: 'samplers', type: 'string', canSync: true },
|
|
||||||
{
|
|
||||||
key: 'pasteLongTextToFileLen',
|
|
||||||
serverKey: 'pasteLongTextToFileLen',
|
|
||||||
type: 'number',
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{ key: 'pdfAsImage', serverKey: 'pdfAsImage', type: 'boolean', canSync: true },
|
|
||||||
{
|
|
||||||
key: 'showThoughtInProgress',
|
|
||||||
serverKey: 'showThoughtInProgress',
|
|
||||||
type: 'boolean',
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{ key: 'keepStatsVisible', serverKey: 'keepStatsVisible', type: 'boolean', canSync: true },
|
|
||||||
{ key: 'showMessageStats', serverKey: 'showMessageStats', type: 'boolean', canSync: true },
|
|
||||||
{
|
|
||||||
key: 'askForTitleConfirmation',
|
|
||||||
serverKey: 'askForTitleConfirmation',
|
|
||||||
type: 'boolean',
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{ key: 'disableAutoScroll', serverKey: 'disableAutoScroll', type: 'boolean', canSync: true },
|
|
||||||
{
|
|
||||||
key: 'renderUserContentAsMarkdown',
|
|
||||||
serverKey: 'renderUserContentAsMarkdown',
|
|
||||||
type: 'boolean',
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{ key: 'autoMicOnEmpty', serverKey: 'autoMicOnEmpty', type: 'boolean', canSync: true },
|
|
||||||
{
|
|
||||||
key: 'pyInterpreterEnabled',
|
|
||||||
serverKey: 'pyInterpreterEnabled',
|
|
||||||
type: 'boolean',
|
|
||||||
canSync: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'enableContinueGeneration',
|
|
||||||
serverKey: 'enableContinueGeneration',
|
|
||||||
type: 'boolean',
|
|
||||||
canSync: true
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export class ParameterSyncService {
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Extraction
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Round floating-point numbers to avoid JavaScript precision issues
|
|
||||||
*/
|
|
||||||
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
|
|
||||||
return normalizeFloatingPoint(value) as ParameterValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract server default parameters that can be synced
|
|
||||||
*/
|
|
||||||
static extractServerDefaults(
|
|
||||||
serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
|
|
||||||
webuiSettings?: Record<string, string | number | boolean>
|
|
||||||
): ParameterRecord {
|
|
||||||
const extracted: ParameterRecord = {};
|
|
||||||
|
|
||||||
if (serverParams) {
|
|
||||||
for (const param of SYNCABLE_PARAMETERS) {
|
|
||||||
if (param.canSync && param.serverKey in serverParams) {
|
|
||||||
const value = (serverParams as unknown as Record<string, ParameterValue>)[
|
|
||||||
param.serverKey
|
|
||||||
];
|
|
||||||
if (value !== undefined) {
|
|
||||||
// Apply precision rounding to avoid JavaScript floating-point issues
|
|
||||||
extracted[param.key] = this.roundFloatingPoint(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle samplers array conversion to string
|
|
||||||
if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
|
|
||||||
extracted.samplers = serverParams.samplers.join(';');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (webuiSettings) {
|
|
||||||
for (const param of SYNCABLE_PARAMETERS) {
|
|
||||||
if (param.canSync && param.serverKey in webuiSettings) {
|
|
||||||
const value = webuiSettings[param.serverKey];
|
|
||||||
if (value !== undefined) {
|
|
||||||
extracted[param.key] = this.roundFloatingPoint(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return extracted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Merging
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge server defaults with current user settings
|
|
||||||
* Returns updated settings that respect user overrides while using server defaults
|
|
||||||
*/
|
|
||||||
static mergeWithServerDefaults(
|
|
||||||
currentSettings: ParameterRecord,
|
|
||||||
serverDefaults: ParameterRecord,
|
|
||||||
userOverrides: Set<string> = new Set()
|
|
||||||
): ParameterRecord {
|
|
||||||
const merged = { ...currentSettings };
|
|
||||||
|
|
||||||
for (const [key, serverValue] of Object.entries(serverDefaults)) {
|
|
||||||
// Only update if user hasn't explicitly overridden this parameter
|
|
||||||
if (!userOverrides.has(key)) {
|
|
||||||
merged[key] = this.roundFloatingPoint(serverValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Info
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get parameter information including source and values
|
|
||||||
*/
|
|
||||||
static getParameterInfo(
|
|
||||||
key: string,
|
|
||||||
currentValue: ParameterValue,
|
|
||||||
propsDefaults: ParameterRecord,
|
|
||||||
userOverrides: Set<string>
|
|
||||||
): ParameterInfo {
|
|
||||||
const hasPropsDefault = propsDefaults[key] !== undefined;
|
|
||||||
const isUserOverride = userOverrides.has(key);
|
|
||||||
|
|
||||||
// Simple logic: either using default (from props) or custom (user override)
|
|
||||||
const source: ParameterSource = isUserOverride ? 'custom' : 'default';
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: currentValue,
|
|
||||||
source,
|
|
||||||
serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
|
|
||||||
userOverride: isUserOverride ? currentValue : undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a parameter can be synced from server
|
|
||||||
*/
|
|
||||||
static canSyncParameter(key: string): boolean {
|
|
||||||
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all syncable parameter keys
|
|
||||||
*/
|
|
||||||
static getSyncableParameterKeys(): string[] {
|
|
||||||
return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate server parameter value
|
|
||||||
*/
|
|
||||||
static validateServerParameter(key: string, value: ParameterValue): boolean {
|
|
||||||
const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
|
|
||||||
if (!param) return false;
|
|
||||||
|
|
||||||
switch (param.type) {
|
|
||||||
case 'number':
|
|
||||||
return typeof value === 'number' && !isNaN(value);
|
|
||||||
case 'string':
|
|
||||||
return typeof value === 'string';
|
|
||||||
case 'boolean':
|
|
||||||
return typeof value === 'boolean';
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Diff
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a diff between current settings and server defaults
|
|
||||||
*/
|
|
||||||
static createParameterDiff(
|
|
||||||
currentSettings: ParameterRecord,
|
|
||||||
serverDefaults: ParameterRecord
|
|
||||||
): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
|
|
||||||
const diff: Record<
|
|
||||||
string,
|
|
||||||
{ current: ParameterValue; server: ParameterValue; differs: boolean }
|
|
||||||
> = {};
|
|
||||||
|
|
||||||
for (const key of this.getSyncableParameterKeys()) {
|
|
||||||
const currentValue = currentSettings[key];
|
|
||||||
const serverValue = serverDefaults[key];
|
|
||||||
|
|
||||||
if (serverValue !== undefined) {
|
|
||||||
diff[key] = {
|
|
||||||
current: currentValue,
|
|
||||||
server: serverValue,
|
|
||||||
differs: currentValue !== serverValue
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return diff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
import { apiFetchWithParams } from '$lib/utils/api-fetch';
|
import { getAuthHeaders } from '$lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PropsService - Server properties management
|
||||||
|
*
|
||||||
|
* This service handles communication with the /props endpoint to retrieve
|
||||||
|
* server configuration, model information, and capabilities.
|
||||||
|
*
|
||||||
|
* **Responsibilities:**
|
||||||
|
* - Fetch server properties from /props endpoint
|
||||||
|
* - Handle API authentication
|
||||||
|
* - Parse and validate server response
|
||||||
|
*
|
||||||
|
* **Used by:**
|
||||||
|
* - serverStore: Primary consumer for server state management
|
||||||
|
*/
|
||||||
export class PropsService {
|
export class PropsService {
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|
@ -10,38 +24,58 @@ export class PropsService {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches global server properties from the `/props` endpoint.
|
* Fetches server properties from the /props endpoint
|
||||||
* In MODEL mode, returns modalities for the single loaded model.
|
|
||||||
* In ROUTER mode, returns server-wide settings without model-specific modalities.
|
|
||||||
*
|
*
|
||||||
* @param autoload - If false, prevents automatic model loading (default: false)
|
* @param autoload - If false, prevents automatic model loading (default: false)
|
||||||
* @returns Server properties including default generation settings and capabilities
|
* @returns {Promise<ApiLlamaCppServerProps>} Server properties
|
||||||
* @throws {Error} If the request fails or returns invalid data
|
* @throws {Error} If the request fails or returns invalid data
|
||||||
*/
|
*/
|
||||||
static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
|
static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
|
||||||
const params: Record<string, string> = {};
|
const url = new URL('./props', window.location.href);
|
||||||
if (!autoload) {
|
if (!autoload) {
|
||||||
params.autoload = 'false';
|
url.searchParams.set('autoload', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, { authOnly: true });
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch server properties: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data as ApiLlamaCppServerProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches server properties for a specific model (ROUTER mode only).
|
* Fetches server properties for a specific model (ROUTER mode)
|
||||||
* Required in ROUTER mode because global `/props` does not include per-model modalities.
|
|
||||||
*
|
*
|
||||||
* @param modelId - The model ID to fetch properties for
|
* @param modelId - The model ID to fetch properties for
|
||||||
* @param autoload - If false, prevents automatic model loading (default: false)
|
* @param autoload - If false, prevents automatic model loading (default: false)
|
||||||
* @returns Server properties specific to the requested model
|
* @returns {Promise<ApiLlamaCppServerProps>} Server properties for the model
|
||||||
* @throws {Error} If the request fails, model not found, or model not loaded
|
* @throws {Error} If the request fails or returns invalid data
|
||||||
*/
|
*/
|
||||||
static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
|
static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
|
||||||
const params: Record<string, string> = { model: modelId };
|
const url = new URL('./props', window.location.href);
|
||||||
|
url.searchParams.set('model', modelId);
|
||||||
if (!autoload) {
|
if (!autoload) {
|
||||||
params.autoload = 'false';
|
url.searchParams.set('autoload', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, { authOnly: true });
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch model properties: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data as ApiLlamaCppServerProps;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { getAuthHeaders } from '$lib/utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PropsService - Server properties management
|
|
||||||
*
|
|
||||||
* This service handles communication with the /props endpoint to retrieve
|
|
||||||
* server configuration, model information, and capabilities.
|
|
||||||
*
|
|
||||||
* **Responsibilities:**
|
|
||||||
* - Fetch server properties from /props endpoint
|
|
||||||
* - Handle API authentication
|
|
||||||
* - Parse and validate server response
|
|
||||||
*
|
|
||||||
* **Used by:**
|
|
||||||
* - serverStore: Primary consumer for server state management
|
|
||||||
*/
|
|
||||||
export class PropsService {
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Fetching
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches server properties from the /props endpoint
|
|
||||||
*
|
|
||||||
* @param autoload - If false, prevents automatic model loading (default: false)
|
|
||||||
* @returns {Promise<ApiLlamaCppServerProps>} Server properties
|
|
||||||
* @throws {Error} If the request fails or returns invalid data
|
|
||||||
*/
|
|
||||||
static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
|
|
||||||
const url = new URL('./props', window.location.href);
|
|
||||||
if (!autoload) {
|
|
||||||
url.searchParams.set('autoload', 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to fetch server properties: ${response.status} ${response.statusText}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data as ApiLlamaCppServerProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches server properties for a specific model (ROUTER mode)
|
|
||||||
*
|
|
||||||
* @param modelId - The model ID to fetch properties for
|
|
||||||
* @param autoload - If false, prevents automatic model loading (default: false)
|
|
||||||
* @returns {Promise<ApiLlamaCppServerProps>} Server properties for the model
|
|
||||||
* @throws {Error} If the request fails or returns invalid data
|
|
||||||
*/
|
|
||||||
static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
|
|
||||||
const url = new URL('./props', window.location.href);
|
|
||||||
url.searchParams.set('model', modelId);
|
|
||||||
if (!autoload) {
|
|
||||||
url.searchParams.set('autoload', 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to fetch model properties: ${response.status} ${response.statusText}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data as ApiLlamaCppServerProps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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:
|
* This store contains ONLY reactive state ($state).
|
||||||
* - Managing the agentic loop lifecycle
|
* All business logic is delegated to AgenticClient.
|
||||||
* - Coordinating between LLM and MCP tool execution
|
|
||||||
* - Tracking session state (messages, turns, tool calls)
|
|
||||||
* - Emitting streaming content and tool results
|
|
||||||
*
|
*
|
||||||
* **Architecture & Relationships:**
|
* **Architecture & Relationships:**
|
||||||
* - **mcpStore**: Provides MCP host manager for tool execution
|
* - **AgenticClient**: Business logic facade (loop orchestration, tool execution)
|
||||||
* - **chatStore**: Triggers agentic flow and receives streaming updates
|
* - **MCPClient**: Tool execution via MCP servers
|
||||||
* - **OpenAISseClient**: LLM communication for streaming responses
|
* - **agenticStore** (this): Reactive state for UI components
|
||||||
* - **settingsStore**: Provides agentic configuration (maxTurns, etc.)
|
|
||||||
*
|
*
|
||||||
* **Key Features:**
|
* **Responsibilities:**
|
||||||
* - Stateful session management (unlike stateless ChatService)
|
* - Hold reactive state for UI binding (isRunning, currentTurn, etc.)
|
||||||
* - Multi-turn tool call orchestration
|
* - Provide getters for computed values
|
||||||
* - Automatic routing of tool calls to appropriate MCP servers
|
* - Expose setters for AgenticClient to update state
|
||||||
* - Raw LLM output streaming (UI formatting is separate concern)
|
* - 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 { browser } from '$app/environment';
|
||||||
import { OpenAISseClient, type OpenAISseTurnResult } from '$lib/clients/openai-sse';
|
import type { AgenticFlowParams, AgenticFlowResult } from '$lib/clients';
|
||||||
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';
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
export type {
|
||||||
// Types
|
AgenticFlowCallbacks,
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
AgenticFlowOptions,
|
||||||
|
AgenticFlowParams,
|
||||||
export interface AgenticFlowCallbacks {
|
AgenticFlowResult
|
||||||
onChunk?: (chunk: string) => void;
|
} from '$lib/clients';
|
||||||
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
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class AgenticStore {
|
class AgenticStore {
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// State
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private _isRunning = $state(false);
|
private _isRunning = $state(false);
|
||||||
private _currentTurn = $state(0);
|
private _currentTurn = $state(0);
|
||||||
private _totalToolCalls = $state(0);
|
private _totalToolCalls = $state(0);
|
||||||
private _lastError = $state<Error | null>(null);
|
private _lastError = $state<Error | null>(null);
|
||||||
|
private _streamingToolCall = $state<{ name: string; arguments: string } | null>(null);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/** Reference to the client (lazy loaded to avoid circular dependency) */
|
||||||
// Getters
|
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 {
|
get isRunning(): boolean {
|
||||||
return this._isRunning;
|
return this._isRunning;
|
||||||
|
|
@ -112,375 +85,37 @@ class AgenticStore {
|
||||||
return this._lastError;
|
return this._lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
get streamingToolCall(): { name: string; arguments: string } | null {
|
||||||
// Main Agentic Flow
|
return this._streamingToolCall;
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the agentic orchestration loop with MCP tools.
|
* Run the agentic orchestration loop with MCP tools.
|
||||||
*
|
* Delegates to AgenticClient.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||||
const { messages, options = {}, callbacks, signal, perChatOverrides } = params;
|
if (!this.client) {
|
||||||
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } =
|
throw new Error('AgenticStore not initialized. Call init() first.');
|
||||||
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)');
|
|
||||||
}
|
}
|
||||||
|
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
|
* Clear error state
|
||||||
*/
|
*/
|
||||||
clearError(): void {
|
clearError(): void {
|
||||||
this._lastError = null;
|
if (!this.client) return;
|
||||||
|
this.client.clearError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Singleton Instance & Exports
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const agenticStore = new AgenticStore();
|
export const agenticStore = new AgenticStore();
|
||||||
|
|
||||||
// Reactive exports for components
|
// Auto-initialize in browser
|
||||||
|
if (browser) {
|
||||||
|
agenticStore.init();
|
||||||
|
}
|
||||||
|
|
||||||
export function agenticIsRunning() {
|
export function agenticIsRunning() {
|
||||||
return agenticStore.isRunning;
|
return agenticStore.isRunning;
|
||||||
}
|
}
|
||||||
|
|
@ -496,3 +131,7 @@ export function agenticTotalToolCalls() {
|
||||||
export function agenticLastError() {
|
export function agenticLastError() {
|
||||||
return agenticStore.lastError;
|
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 { 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 { AttachmentType } from '$lib/enums';
|
||||||
import type { McpServerOverride } from '$lib/types/database';
|
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 {
|
class ConversationsStore {
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// State
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** List of all conversations */
|
/** List of all conversations */
|
||||||
conversations = $state<DatabaseConversation[]>([]);
|
conversations = $state<DatabaseConversation[]>([]);
|
||||||
|
|
||||||
|
|
@ -69,22 +42,57 @@ class ConversationsStore {
|
||||||
/** Callback for title update confirmation dialog */
|
/** Callback for title update confirmation dialog */
|
||||||
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Modalities
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modalities used in the active conversation.
|
* Modalities used in the active conversation.
|
||||||
* Computed from attachments in activeMessages.
|
* Computed from attachments in activeMessages.
|
||||||
* Used to filter available models - models must support all used modalities.
|
|
||||||
*/
|
*/
|
||||||
usedModalities: ModelModalities = $derived.by(() => {
|
usedModalities: ModelModalities = $derived.by(() => {
|
||||||
return this.calculateModalitiesFromMessages(this.activeMessages);
|
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.
|
* Calculate modalities from a list of messages.
|
||||||
* Helper method used by both usedModalities and getModalitiesUpToMessage.
|
|
||||||
*/
|
*/
|
||||||
private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
|
private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
|
||||||
const modalities: ModelModalities = { vision: false, audio: false };
|
const modalities: ModelModalities = { vision: false, audio: false };
|
||||||
|
|
@ -119,7 +127,6 @@ class ConversationsStore {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get modalities used in messages BEFORE the specified message.
|
* 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 {
|
getModalitiesUpToMessage(messageId: string): ModelModalities {
|
||||||
const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
|
const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
|
||||||
|
|
@ -132,596 +139,8 @@ class ConversationsStore {
|
||||||
return this.calculateModalitiesFromMessages(messagesBefore);
|
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
|
* 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 {
|
addMessageToActive(message: DatabaseMessage): void {
|
||||||
this.activeMessages.push(message);
|
this.activeMessages.push(message);
|
||||||
|
|
@ -729,21 +148,15 @@ class ConversationsStore {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a message at a specific index in active messages
|
* 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 {
|
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
|
||||||
if (index !== -1 && this.activeMessages[index]) {
|
if (index !== -1 && this.activeMessages[index]) {
|
||||||
// Create new object to trigger Svelte 5 reactivity
|
|
||||||
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
|
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the index of a message in active messages
|
* 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 {
|
findMessageIndex(messageId: string): number {
|
||||||
return this.activeMessages.findIndex((m) => m.id === messageId);
|
return this.activeMessages.findIndex((m) => m.id === messageId);
|
||||||
|
|
@ -751,7 +164,6 @@ class ConversationsStore {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes messages from active messages starting at an index
|
* Removes messages from active messages starting at an index
|
||||||
* @param startIndex - The index to start removing from
|
|
||||||
*/
|
*/
|
||||||
sliceActiveMessages(startIndex: number): void {
|
sliceActiveMessages(startIndex: number): void {
|
||||||
this.activeMessages = this.activeMessages.slice(0, startIndex);
|
this.activeMessages = this.activeMessages.slice(0, startIndex);
|
||||||
|
|
@ -759,8 +171,6 @@ class ConversationsStore {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a message from active messages by index
|
* 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 {
|
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
|
|
@ -769,56 +179,156 @@ class ConversationsStore {
|
||||||
return undefined;
|
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
|
* Sets the callback function for title update confirmations
|
||||||
* @param callback - Function to call when confirmation is needed
|
|
||||||
*/
|
*/
|
||||||
setTitleUpdateConfirmationCallback(
|
setTitleUpdateConfirmationCallback(
|
||||||
callback: (currentTitle: string, newTitle: string) => Promise<boolean>
|
callback: (currentTitle: string, newTitle: string) => Promise<boolean>
|
||||||
): void {
|
): void {
|
||||||
this.titleUpdateConfirmationCallback = callback;
|
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();
|
export const conversationsStore = new ConversationsStore();
|
||||||
|
|
||||||
|
// Auto-initialize in browser
|
||||||
|
if (browser) {
|
||||||
|
conversationsStore.init();
|
||||||
|
}
|
||||||
|
|
||||||
export const conversations = () => conversationsStore.conversations;
|
export const conversations = () => conversationsStore.conversations;
|
||||||
export const activeConversation = () => conversationsStore.activeConversation;
|
export const activeConversation = () => conversationsStore.activeConversation;
|
||||||
export const activeMessages = () => conversationsStore.activeMessages;
|
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:
|
* This store contains ONLY reactive state ($state, $derived).
|
||||||
* - MCPHostManager lifecycle (initialization, shutdown)
|
* All business logic is delegated to MCPClient.
|
||||||
* - Connection state tracking for multiple MCP servers
|
|
||||||
* - Aggregated tools from all connected MCP servers
|
|
||||||
* - Error handling for MCP operations
|
|
||||||
*
|
*
|
||||||
* **Architecture & Relationships:**
|
* **Architecture & Relationships:**
|
||||||
* - **MCPHostManager**: Coordinates multiple MCPServerConnection instances
|
* - **MCPClient**: Business logic facade (lifecycle, tool execution, health checks)
|
||||||
* - **MCPServerConnection**: Single SDK Client wrapper per server
|
* - **MCPService**: Stateless protocol layer (transport, connect, callTool)
|
||||||
* - **mcpStore** (this class): Reactive Svelte store for MCP state
|
* - **mcpStore** (this): Reactive state for UI components
|
||||||
* - **agenticStore**: Uses mcpStore for tool execution in agentic loop
|
|
||||||
* - **settingsStore**: Provides MCP server configuration
|
|
||||||
*
|
*
|
||||||
* **Key Features:**
|
* **Responsibilities:**
|
||||||
* - Reactive state with Svelte 5 runes ($state, $derived)
|
* - Hold reactive state for UI binding
|
||||||
* - Automatic reinitialization on config changes
|
* - Provide getters for computed values
|
||||||
* - Aggregates tools from multiple servers
|
* - Expose setters for MCPClient to update state
|
||||||
* - Routes tool calls to appropriate server automatically
|
* - Forward method calls to MCPClient
|
||||||
* - Graceful error handling with fallback to standard chat
|
*
|
||||||
|
* @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 _isInitializing = $state(false);
|
||||||
private _error = $state<string | null>(null);
|
private _error = $state<string | null>(null);
|
||||||
private _configSignature = $state<string | null>(null);
|
private _toolCount = $state(0);
|
||||||
private _initPromise: Promise<MCPHostManager | undefined> | null = null;
|
private _connectedServers = $state<string[]>([]);
|
||||||
|
|
||||||
// Health check state (in-memory only, not persisted)
|
|
||||||
private _healthChecks = $state<Record<string, HealthCheckState>>({});
|
private _healthChecks = $state<Record<string, HealthCheckState>>({});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
constructor() {
|
||||||
// Computed Getters
|
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 {
|
mcpClient.setHealthCheckCallback((serverId, state) => {
|
||||||
return this._hostManager;
|
this._healthChecks = { ...this._healthChecks, [serverId]: state };
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get isInitializing(): boolean {
|
get isInitializing(): boolean {
|
||||||
|
|
@ -69,13 +68,25 @@ class MCPStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
get isInitialized(): boolean {
|
get isInitialized(): boolean {
|
||||||
return this._hostManager?.isInitialized ?? false;
|
return mcpClient.isInitialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
get error(): string | null {
|
get error(): string | null {
|
||||||
return this._error;
|
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)
|
* 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[] {
|
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 {
|
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
|
||||||
return this._hostManager?.connectedServerCount ?? 0;
|
return mcpClient.ensureInitialized(perChatOverrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get names of connected servers
|
* Shutdown MCP connections and clear state
|
||||||
*/
|
*/
|
||||||
get connectedServerNames(): string[] {
|
async shutdown(): Promise<void> {
|
||||||
return this._hostManager?.connectedServerNames ?? [];
|
return mcpClient.shutdown();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get total tool count
|
|
||||||
*/
|
|
||||||
get toolCount(): number {
|
|
||||||
return this._hostManager?.toolCount ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tool definitions for LLM (OpenAI function calling format)
|
* Get tool definitions for LLM (OpenAI function calling format)
|
||||||
*/
|
*/
|
||||||
getToolDefinitions(): OpenAIToolDefinition[] {
|
getToolDefinitions(): OpenAIToolDefinition[] {
|
||||||
return this._hostManager?.getToolDefinitionsForLLM() ?? [];
|
return mcpClient.getToolDefinitionsForLLM();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get status of all servers
|
* Get status of all servers
|
||||||
*/
|
*/
|
||||||
getServersStatus(): ServerStatus[] {
|
getServersStatus(): ServerStatus[] {
|
||||||
return this._hostManager?.getServersStatus() ?? [];
|
return mcpClient.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!);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize MCP host manager with given config
|
* Execute a tool call via MCP.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
||||||
if (!this._hostManager) {
|
return mcpClient.executeTool(toolCall, signal);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a tool by name with arguments.
|
* Execute a tool by name with arguments.
|
||||||
* Simpler interface for direct tool calls.
|
|
||||||
*/
|
*/
|
||||||
async executeToolByName(
|
async executeToolByName(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<ToolExecutionResult> {
|
): Promise<ToolExecutionResult> {
|
||||||
if (!this._hostManager) {
|
return mcpClient.executeToolByName(toolName, args, signal);
|
||||||
throw new Error('MCP host manager not initialized');
|
|
||||||
}
|
|
||||||
return this._hostManager.executeToolByName(toolName, args, signal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a tool exists
|
* Check if a tool exists
|
||||||
*/
|
*/
|
||||||
hasTool(toolName: string): boolean {
|
hasTool(toolName: string): boolean {
|
||||||
return this._hostManager?.hasTool(toolName) ?? false;
|
return mcpClient.hasTool(toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get which server provides a specific tool
|
* Get which server provides a specific tool
|
||||||
*/
|
*/
|
||||||
getToolServer(toolName: string): string | undefined {
|
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
|
* Get health check state for a specific server
|
||||||
*/
|
*/
|
||||||
|
|
@ -333,13 +172,6 @@ class MCPStore {
|
||||||
return this._healthChecks[serverId] ?? { status: 'idle' };
|
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
|
* 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';
|
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
|
* Run health check for a specific server
|
||||||
*/
|
*/
|
||||||
async runHealthCheck(server: {
|
async runHealthCheck(server: HealthCheckParams): Promise<void> {
|
||||||
id: string;
|
return mcpClient.runHealthCheck(server);
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -438,19 +201,17 @@ class MCPStore {
|
||||||
clearAllHealthChecks(): void {
|
clearAllHealthChecks(): void {
|
||||||
this._healthChecks = {};
|
this._healthChecks = {};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Singleton Instance & Exports
|
* Clear error state
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*/
|
||||||
|
clearError(): void {
|
||||||
|
this._error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const mcpStore = new MCPStore();
|
export const mcpStore = new MCPStore();
|
||||||
|
|
||||||
// Reactive exports for components
|
|
||||||
export function mcpHostManager() {
|
|
||||||
return mcpStore.hostManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mcpIsInitializing() {
|
export function mcpIsInitializing() {
|
||||||
return mcpStore.isInitializing;
|
return mcpStore.isInitializing;
|
||||||
}
|
}
|
||||||
|
|
@ -483,7 +244,6 @@ export function mcpToolCount() {
|
||||||
return mcpStore.toolCount;
|
return mcpStore.toolCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check exports
|
|
||||||
export function mcpGetHealthCheckState(serverId: string) {
|
export function mcpGetHealthCheckState(serverId: string) {
|
||||||
return mcpStore.getHealthCheckState(serverId);
|
return mcpStore.getHealthCheckState(serverId);
|
||||||
}
|
}
|
||||||
|
|
@ -492,12 +252,7 @@ export function mcpHasHealthCheck(serverId: string) {
|
||||||
return mcpStore.hasHealthCheck(serverId);
|
return mcpStore.hasHealthCheck(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mcpRunHealthCheck(server: {
|
export async function mcpRunHealthCheck(server: HealthCheckParams) {
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
requestTimeoutSeconds: number;
|
|
||||||
headers?: string;
|
|
||||||
}) {
|
|
||||||
return mcpStore.runHealthCheck(server);
|
return mcpStore.runHealthCheck(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { ModelsService } from '$lib/services/models';
|
import { ModelsService } from '$lib/services/models.service';
|
||||||
import { PropsService } from '$lib/services/props';
|
|
||||||
import { ServerModelStatus, ModelModality } from '$lib/enums';
|
import { ServerModelStatus, ModelModality } from '$lib/enums';
|
||||||
import { serverStore } from '$lib/stores/server.svelte';
|
import { serverStore } from '$lib/stores/server.svelte';
|
||||||
|
import { PropsService } from '$lib/services';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
|
* 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
|
* - **Lazy loading**: ensureModelLoaded() loads models on demand
|
||||||
*/
|
*/
|
||||||
class ModelsStore {
|
class ModelsStore {
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// State
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* State
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
models = $state<ModelOption[]>([]);
|
models = $state<ModelOption[]>([]);
|
||||||
routerModels = $state<ApiModelDataEntry[]>([]);
|
routerModels = $state<ApiModelDataEntry[]>([]);
|
||||||
|
|
@ -59,9 +63,13 @@ class ModelsStore {
|
||||||
*/
|
*/
|
||||||
propsCacheVersion = $state(0);
|
propsCacheVersion = $state(0);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Computed Getters
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Computed Getters
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
get selectedModel(): ModelOption | null {
|
get selectedModel(): ModelOption | null {
|
||||||
if (!this.selectedModelId) return null;
|
if (!this.selectedModelId) return null;
|
||||||
|
|
@ -95,22 +103,24 @@ class ModelsStore {
|
||||||
return props.model_path.split(/(\\|\/)/).pop() || null;
|
return props.model_path.split(/(\\|\/)/).pop() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Modalities
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Modalities
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get modalities for a specific model
|
* Get modalities for a specific model
|
||||||
* Returns cached modalities from model props
|
* Returns cached modalities from model props
|
||||||
*/
|
*/
|
||||||
getModelModalities(modelId: string): ModelModalities | null {
|
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);
|
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
|
||||||
if (model?.modalities) {
|
if (model?.modalities) {
|
||||||
return model.modalities;
|
return model.modalities;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to props cache
|
|
||||||
const props = this.modelPropsCache.get(modelId);
|
const props = this.modelPropsCache.get(modelId);
|
||||||
if (props?.modalities) {
|
if (props?.modalities) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -181,9 +191,13 @@ class ModelsStore {
|
||||||
return this.modelPropsFetching.has(modelId);
|
return this.modelPropsFetching.has(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Status Queries
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Status Queries
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
isModelLoaded(modelId: string): boolean {
|
isModelLoaded(modelId: string): boolean {
|
||||||
const model = this.routerModels.find((m) => m.id === modelId);
|
const model = this.routerModels.find((m) => m.id === modelId);
|
||||||
|
|
@ -208,9 +222,13 @@ class ModelsStore {
|
||||||
return usage !== undefined && usage.size > 0;
|
return usage !== undefined && usage.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Data Fetching
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Data Fetching
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch list of models from server and detect server role
|
* Fetch list of models from server and detect server role
|
||||||
|
|
@ -224,7 +242,6 @@ class ModelsStore {
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure server props are loaded (for role detection and MODEL mode modalities)
|
|
||||||
if (!serverStore.props) {
|
if (!serverStore.props) {
|
||||||
await serverStore.fetch();
|
await serverStore.fetch();
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +268,6 @@ class ModelsStore {
|
||||||
|
|
||||||
this.models = models;
|
this.models = models;
|
||||||
|
|
||||||
// In MODEL mode, populate modalities from serverStore.props (single model)
|
|
||||||
// WORKAROUND: In MODEL mode, /props returns modalities for the 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.
|
// but /v1/models doesn't include modalities. We bridge this gap here.
|
||||||
const serverProps = serverStore.props;
|
const serverProps = serverStore.props;
|
||||||
|
|
@ -260,9 +276,7 @@ class ModelsStore {
|
||||||
vision: serverProps.modalities.vision ?? false,
|
vision: serverProps.modalities.vision ?? false,
|
||||||
audio: serverProps.modalities.audio ?? false
|
audio: serverProps.modalities.audio ?? false
|
||||||
};
|
};
|
||||||
// Cache props for the single model
|
|
||||||
this.modelPropsCache.set(this.models[0].model, serverProps);
|
this.modelPropsCache.set(this.models[0].model, serverProps);
|
||||||
// Update model with modalities
|
|
||||||
this.models = this.models.map((model, index) =>
|
this.models = this.models.map((model, index) =>
|
||||||
index === 0 ? { ...model, modalities } : model
|
index === 0 ? { ...model, modalities } : model
|
||||||
);
|
);
|
||||||
|
|
@ -302,7 +316,6 @@ class ModelsStore {
|
||||||
* @returns Props data or null if fetch failed or model not loaded
|
* @returns Props data or null if fetch failed or model not loaded
|
||||||
*/
|
*/
|
||||||
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
|
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
|
||||||
// Return cached props if available
|
|
||||||
const cached = this.modelPropsCache.get(modelId);
|
const cached = this.modelPropsCache.get(modelId);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
|
|
@ -310,7 +323,6 @@ class ModelsStore {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid duplicate fetches
|
|
||||||
if (this.modelPropsFetching.has(modelId)) return null;
|
if (this.modelPropsFetching.has(modelId)) return null;
|
||||||
|
|
||||||
this.modelPropsFetching.add(modelId);
|
this.modelPropsFetching.add(modelId);
|
||||||
|
|
@ -335,7 +347,6 @@ class ModelsStore {
|
||||||
const loadedModelIds = this.loadedModelIds;
|
const loadedModelIds = this.loadedModelIds;
|
||||||
if (loadedModelIds.length === 0) return;
|
if (loadedModelIds.length === 0) return;
|
||||||
|
|
||||||
// Fetch props for each loaded model in parallel
|
|
||||||
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
|
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -357,7 +368,6 @@ class ModelsStore {
|
||||||
return { ...model, modalities };
|
return { ...model, modalities };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Increment version to trigger reactivity
|
|
||||||
this.propsCacheVersion++;
|
this.propsCacheVersion++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch modalities for loaded models:', error);
|
console.warn('Failed to fetch modalities for loaded models:', error);
|
||||||
|
|
@ -382,16 +392,19 @@ class ModelsStore {
|
||||||
model.model === modelId ? { ...model, modalities } : model
|
model.model === modelId ? { ...model, modalities } : model
|
||||||
);
|
);
|
||||||
|
|
||||||
// Increment version to trigger reactivity
|
|
||||||
this.propsCacheVersion++;
|
this.propsCacheVersion++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to update modalities for model ${modelId}:`, error);
|
console.warn(`Failed to update modalities for model ${modelId}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Model Selection
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Model Selection
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a model for new conversations
|
* Select a model for new conversations
|
||||||
|
|
@ -443,9 +456,13 @@ class ModelsStore {
|
||||||
return this.models.some((model) => model.model === modelName);
|
return this.models.some((model) => model.model === modelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Loading/Unloading Models
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Loading/Unloading Models
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WORKAROUND: Polling for model status after load/unload operations.
|
* WORKAROUND: Polling for model status after load/unload operations.
|
||||||
|
|
@ -486,7 +503,6 @@ class ModelsStore {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait before next poll
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
|
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -511,8 +527,6 @@ class ModelsStore {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ModelsService.load(modelId);
|
await ModelsService.load(modelId);
|
||||||
|
|
||||||
// Poll until model is loaded
|
|
||||||
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
|
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
|
||||||
|
|
||||||
await this.updateModelModalities(modelId);
|
await this.updateModelModalities(modelId);
|
||||||
|
|
@ -562,9 +576,13 @@ class ModelsStore {
|
||||||
await this.loadModel(modelId);
|
await this.loadModel(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Utilities
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Utilities
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
private toDisplayName(id: string): string {
|
private toDisplayName(id: string): string {
|
||||||
const segments = id.split(/\\|\//);
|
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';
|
import { ServerRole } from '$lib/enums';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -18,9 +18,13 @@ import { ServerRole } from '$lib/enums';
|
||||||
* - **Default Params**: Server-wide generation defaults
|
* - **Default Params**: Server-wide generation defaults
|
||||||
*/
|
*/
|
||||||
class ServerStore {
|
class ServerStore {
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// State
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* State
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
props = $state<ApiLlamaCppServerProps | null>(null);
|
props = $state<ApiLlamaCppServerProps | null>(null);
|
||||||
loading = $state(false);
|
loading = $state(false);
|
||||||
|
|
@ -28,9 +32,13 @@ class ServerStore {
|
||||||
role = $state<ServerRole | null>(null);
|
role = $state<ServerRole | null>(null);
|
||||||
private fetchPromise: Promise<void> | null = null;
|
private fetchPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Getters
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Getters
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
|
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
|
||||||
return this.props?.default_generation_settings?.params || null;
|
return this.props?.default_generation_settings?.params || null;
|
||||||
|
|
@ -52,9 +60,13 @@ class ServerStore {
|
||||||
return this.role === ServerRole.MODEL;
|
return this.role === ServerRole.MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Data Handling
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Data Handling
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
async fetch(): Promise<void> {
|
async fetch(): Promise<void> {
|
||||||
if (this.fetchPromise) return this.fetchPromise;
|
if (this.fetchPromise) return this.fetchPromise;
|
||||||
|
|
@ -115,9 +127,13 @@ class ServerStore {
|
||||||
this.fetchPromise = null;
|
this.fetchPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Utilities
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Utilities
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
private detectRole(props: ApiLlamaCppServerProps): void {
|
private detectRole(props: ApiLlamaCppServerProps): void {
|
||||||
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
|
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
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 { serverStore } from '$lib/stores/server.svelte';
|
||||||
import {
|
import {
|
||||||
configToParameterRecord,
|
configToParameterRecord,
|
||||||
|
|
@ -47,18 +47,26 @@ import {
|
||||||
} from '$lib/constants/localstorage-keys';
|
} from '$lib/constants/localstorage-keys';
|
||||||
|
|
||||||
class SettingsStore {
|
class SettingsStore {
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// State
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* State
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
|
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
|
||||||
theme = $state<string>('auto');
|
theme = $state<string>('auto');
|
||||||
isInitialized = $state(false);
|
isInitialized = $state(false);
|
||||||
userOverrides = $state<Set<string>>(new Set());
|
userOverrides = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Utilities (private helpers)
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Utilities (private helpers)
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper method to get server defaults with null safety
|
* 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
|
* Initialize the settings store by loading from localStorage
|
||||||
|
|
@ -130,9 +142,13 @@ class SettingsStore {
|
||||||
|
|
||||||
this.theme = localStorage.getItem('theme') || 'auto';
|
this.theme = localStorage.getItem('theme') || 'auto';
|
||||||
}
|
}
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Config Updates
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Config Updates
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a specific configuration setting
|
* Update a specific configuration setting
|
||||||
|
|
@ -234,9 +250,13 @@ class SettingsStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Reset
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Reset
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset configuration to defaults
|
* Reset configuration to defaults
|
||||||
|
|
@ -285,9 +305,13 @@ class SettingsStore {
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Server Sync
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Server Sync
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize settings with props defaults when server properties are first loaded
|
* Initialize settings with props defaults when server properties are first loaded
|
||||||
|
|
@ -349,9 +373,13 @@ class SettingsStore {
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// Utilities
|
*
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
|
* Utilities
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific configuration value
|
* Get a specific configuration value
|
||||||
|
|
|
||||||
|
|
@ -49,4 +49,37 @@ export interface ChatMessageTimings {
|
||||||
predicted_n?: number;
|
predicted_n?: number;
|
||||||
prompt_ms?: number;
|
prompt_ms?: number;
|
||||||
prompt_n?: 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;
|
id: string;
|
||||||
lastModified: number;
|
lastModified: number;
|
||||||
name: string;
|
name: string;
|
||||||
/** Per-chat MCP server overrides. If not set, global settings are used. */
|
|
||||||
mcpServerOverrides?: McpServerOverride[];
|
mcpServerOverrides?: McpServerOverride[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,9 +41,9 @@ export interface DatabaseMessageExtraPdfFile {
|
||||||
type: AttachmentType.PDF;
|
type: AttachmentType.PDF;
|
||||||
base64Data: string;
|
base64Data: string;
|
||||||
name: string;
|
name: string;
|
||||||
content: string; // Text content extracted from PDF
|
content: string;
|
||||||
images?: string[]; // Optional: PDF pages as base64 images
|
images?: string[];
|
||||||
processedAsImages: boolean; // Whether PDF was processed as images
|
processedAsImages: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseMessageExtraTextFile {
|
export interface DatabaseMessageExtraTextFile {
|
||||||
|
|
@ -76,17 +75,9 @@ export interface DatabaseMessage {
|
||||||
model?: string;
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a single conversation with its associated messages,
|
|
||||||
* typically used for import/export operations.
|
|
||||||
*/
|
|
||||||
export type ExportedConversation = {
|
export type ExportedConversation = {
|
||||||
conv: DatabaseConversation;
|
conv: DatabaseConversation;
|
||||||
messages: DatabaseMessage[];
|
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[];
|
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,5 +1,6 @@
|
||||||
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||||
import type { ChatMessageTimings } from './chat';
|
import type { ChatMessageTimings } from './chat';
|
||||||
|
import type { OpenAIToolDefinition } from './mcp';
|
||||||
|
|
||||||
export type SettingsConfigValue = string | number | boolean;
|
export type SettingsConfigValue = string | number | boolean;
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ export interface SettingsChatServiceOptions {
|
||||||
systemMessage?: string;
|
systemMessage?: string;
|
||||||
// Disable reasoning parsing (use 'none' instead of 'auto')
|
// Disable reasoning parsing (use 'none' instead of 'auto')
|
||||||
disableReasoningParsing?: boolean;
|
disableReasoningParsing?: boolean;
|
||||||
|
tools?: OpenAIToolDefinition[];
|
||||||
// Generation parameters
|
// Generation parameters
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,41 @@
|
||||||
import type { ApiChatMessageData } from '$lib/types/api';
|
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.
|
* 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -96,3 +96,6 @@ export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files
|
||||||
|
|
||||||
// Agentic utilities
|
// Agentic utilities
|
||||||
export { toAgenticMessages } from './agentic';
|
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.
|
* Represents a key-value pair for HTTP headers.
|
||||||
|
|
@ -73,7 +83,7 @@ export function parseHeadersToArray(headersJson: string): HeaderPair[] {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid JSON, return empty
|
return [];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -91,3 +101,199 @@ export function serializeHeaders(pairs: HeaderPair[]): string {
|
||||||
}
|
}
|
||||||
return JSON.stringify(obj);
|
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