webui: Agentic Loop + MCP Client with support for Tools, Resources and Prompts (#18655)
This commit is contained in:
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* agenticStore - Reactive State Store for Agentic Loop Orchestration
|
||||
*
|
||||
* Manages multi-turn agentic loop with MCP tools:
|
||||
* - LLM streaming with tool call detection
|
||||
* - Tool execution via mcpStore
|
||||
* - Session state management
|
||||
* - Turn limit enforcement
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ChatService**: Stateless API layer (sendMessage, streaming)
|
||||
* - **mcpStore**: MCP connection management and tool execution
|
||||
* - **agenticStore** (this): Reactive state + business logic
|
||||
*
|
||||
* @see ChatService in services/chat.service.ts for API operations
|
||||
* @see mcpStore in stores/mcp.svelte.ts for MCP operations
|
||||
*/
|
||||
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { ChatService } from '$lib/services';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { isAbortError } from '$lib/utils';
|
||||
import {
|
||||
DEFAULT_AGENTIC_CONFIG,
|
||||
AGENTIC_TAGS,
|
||||
NEWLINE_SEPARATOR,
|
||||
TURN_LIMIT_MESSAGE,
|
||||
LLM_ERROR_BLOCK_START,
|
||||
LLM_ERROR_BLOCK_END
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
IMAGE_MIME_TO_EXTENSION,
|
||||
DATA_URI_BASE64_REGEX,
|
||||
MCP_ATTACHMENT_NAME_PREFIX,
|
||||
DEFAULT_IMAGE_EXTENSION
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
AttachmentType,
|
||||
ContentPartType,
|
||||
MessageRole,
|
||||
MimeTypePrefix,
|
||||
ToolCallType
|
||||
} from '$lib/enums';
|
||||
import type {
|
||||
AgenticFlowParams,
|
||||
AgenticFlowResult,
|
||||
AgenticSession,
|
||||
AgenticConfig,
|
||||
SettingsConfigType,
|
||||
McpServerOverride,
|
||||
MCPToolCall
|
||||
} from '$lib/types';
|
||||
import type {
|
||||
AgenticMessage,
|
||||
AgenticToolCallList,
|
||||
AgenticFlowCallbacks,
|
||||
AgenticFlowOptions
|
||||
} from '$lib/types/agentic';
|
||||
import type {
|
||||
ApiChatCompletionToolCall,
|
||||
ApiChatMessageData,
|
||||
ApiChatMessageContentPart
|
||||
} from '$lib/types/api';
|
||||
import type {
|
||||
ChatMessagePromptProgress,
|
||||
ChatMessageTimings,
|
||||
ChatMessageAgenticTimings,
|
||||
ChatMessageToolCallTiming,
|
||||
ChatMessageAgenticTurnStats
|
||||
} from '$lib/types/chat';
|
||||
import type {
|
||||
DatabaseMessage,
|
||||
DatabaseMessageExtra,
|
||||
DatabaseMessageExtraImageFile
|
||||
} from '$lib/types/database';
|
||||
|
||||
function createDefaultSession(): AgenticSession {
|
||||
return {
|
||||
isRunning: false,
|
||||
currentTurn: 0,
|
||||
totalToolCalls: 0,
|
||||
lastError: null,
|
||||
streamingToolCall: null
|
||||
};
|
||||
}
|
||||
|
||||
function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (
|
||||
message.role === MessageRole.ASSISTANT &&
|
||||
message.tool_calls &&
|
||||
message.tool_calls.length > 0
|
||||
) {
|
||||
return {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: message.content,
|
||||
tool_calls: message.tool_calls.map((call, index) => ({
|
||||
id: call.id ?? `call_${index}`,
|
||||
type: (call.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION,
|
||||
function: { name: call.function?.name ?? '', arguments: call.function?.arguments ?? '' }
|
||||
}))
|
||||
} satisfies AgenticMessage;
|
||||
}
|
||||
if (message.role === MessageRole.TOOL && message.tool_call_id) {
|
||||
return {
|
||||
role: MessageRole.TOOL,
|
||||
tool_call_id: message.tool_call_id,
|
||||
content: typeof message.content === 'string' ? message.content : ''
|
||||
} satisfies AgenticMessage;
|
||||
}
|
||||
return {
|
||||
role: message.role as MessageRole.SYSTEM | MessageRole.USER,
|
||||
content: message.content
|
||||
} satisfies AgenticMessage;
|
||||
});
|
||||
}
|
||||
|
||||
class AgenticStore {
|
||||
private _sessions = $state<Map<string, AgenticSession>>(new Map());
|
||||
|
||||
get isReady(): boolean {
|
||||
return true;
|
||||
}
|
||||
get isAnyRunning(): boolean {
|
||||
for (const session of this._sessions.values()) {
|
||||
if (session.isRunning) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getSession(conversationId: string): AgenticSession {
|
||||
let session = this._sessions.get(conversationId);
|
||||
if (!session) {
|
||||
session = createDefaultSession();
|
||||
this._sessions.set(conversationId, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
|
||||
const session = this.getSession(conversationId);
|
||||
this._sessions.set(conversationId, { ...session, ...update });
|
||||
}
|
||||
|
||||
clearSession(conversationId: string): void {
|
||||
this._sessions.delete(conversationId);
|
||||
}
|
||||
|
||||
getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> {
|
||||
const active: Array<{ conversationId: string; session: AgenticSession }> = [];
|
||||
for (const [conversationId, session] of this._sessions.entries()) {
|
||||
if (session.isRunning) active.push({ conversationId, session });
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
isRunning(conversationId: string): boolean {
|
||||
return this.getSession(conversationId).isRunning;
|
||||
}
|
||||
|
||||
currentTurn(conversationId: string): number {
|
||||
return this.getSession(conversationId).currentTurn;
|
||||
}
|
||||
|
||||
totalToolCalls(conversationId: string): number {
|
||||
return this.getSession(conversationId).totalToolCalls;
|
||||
}
|
||||
|
||||
lastError(conversationId: string): Error | null {
|
||||
return this.getSession(conversationId).lastError;
|
||||
}
|
||||
|
||||
streamingToolCall(conversationId: string): { name: string; arguments: string } | null {
|
||||
return this.getSession(conversationId).streamingToolCall;
|
||||
}
|
||||
|
||||
clearError(conversationId: string): void {
|
||||
this.updateSession(conversationId, { lastError: null });
|
||||
}
|
||||
|
||||
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
|
||||
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
|
||||
const maxToolPreviewLines =
|
||||
Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines;
|
||||
return {
|
||||
enabled: mcpStore.hasEnabledServers(perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
maxTurns,
|
||||
maxToolPreviewLines
|
||||
};
|
||||
}
|
||||
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
|
||||
const {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onError,
|
||||
onTimings,
|
||||
onTurnComplete
|
||||
} = callbacks;
|
||||
|
||||
const agenticConfig = this.getConfig(config(), perChatOverrides);
|
||||
if (!agenticConfig.enabled) return { handled: false };
|
||||
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
if (!initialized) {
|
||||
console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
const tools = mcpStore.getToolDefinitionsForLLM();
|
||||
if (tools.length === 0) {
|
||||
console.log('[AgenticStore] No tools available, falling back to standard chat');
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`);
|
||||
|
||||
const normalizedMessages: ApiChatMessageData[] = messages
|
||||
.map((msg) => {
|
||||
if ('id' in msg && 'convId' in msg && 'timestamp' in msg)
|
||||
return ChatService.convertDbMessageToApiChatMessageData(
|
||||
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
||||
);
|
||||
return msg as ApiChatMessageData;
|
||||
})
|
||||
.filter((msg) => {
|
||||
if (msg.role === MessageRole.SYSTEM) {
|
||||
const content = typeof msg.content === 'string' ? msg.content : '';
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
this.updateSession(conversationId, {
|
||||
isRunning: true,
|
||||
currentTurn: 0,
|
||||
totalToolCalls: 0,
|
||||
lastError: null
|
||||
});
|
||||
mcpStore.acquireConnection();
|
||||
|
||||
try {
|
||||
await this.executeAgenticLoop({
|
||||
conversationId,
|
||||
messages: normalizedMessages,
|
||||
options,
|
||||
tools,
|
||||
agenticConfig,
|
||||
callbacks: {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onError,
|
||||
onTimings,
|
||||
onTurnComplete
|
||||
},
|
||||
signal
|
||||
});
|
||||
return { handled: true };
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
this.updateSession(conversationId, { lastError: normalizedError });
|
||||
onError?.(normalizedError);
|
||||
return { handled: true, error: normalizedError };
|
||||
} finally {
|
||||
this.updateSession(conversationId, { isRunning: false });
|
||||
await mcpStore
|
||||
.releaseConnection()
|
||||
.catch((err: unknown) =>
|
||||
console.warn('[AgenticStore] Failed to release MCP connection:', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAgenticLoop(params: {
|
||||
conversationId: string;
|
||||
messages: ApiChatMessageData[];
|
||||
options: AgenticFlowOptions;
|
||||
tools: ReturnType<typeof mcpStore.getToolDefinitionsForLLM>;
|
||||
agenticConfig: AgenticConfig;
|
||||
callbacks: AgenticFlowCallbacks;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { conversationId, messages, options, tools, agenticConfig, callbacks, signal } = params;
|
||||
const {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onTimings,
|
||||
onTurnComplete
|
||||
} = callbacks;
|
||||
|
||||
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
|
||||
const allToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let capturedTimings: ChatMessageTimings | undefined;
|
||||
|
||||
const agenticTimings: ChatMessageAgenticTimings = {
|
||||
turns: 0,
|
||||
toolCallsCount: 0,
|
||||
toolsMs: 0,
|
||||
toolCalls: [],
|
||||
perTurn: [],
|
||||
llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }
|
||||
};
|
||||
const maxTurns = agenticConfig.maxTurns;
|
||||
const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
|
||||
|
||||
for (let turn = 0; turn < maxTurns; turn++) {
|
||||
this.updateSession(conversationId, { currentTurn: turn + 1 });
|
||||
agenticTimings.turns = turn + 1;
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let turnContent = '';
|
||||
let turnToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let lastStreamingToolCallName = '';
|
||||
let lastStreamingToolCallArgsLength = 0;
|
||||
const emittedToolCallStates = new SvelteMap<
|
||||
number,
|
||||
{ emittedOnce: boolean; lastArgs: string }
|
||||
>();
|
||||
let turnTimings: ChatMessageTimings | undefined;
|
||||
|
||||
const turnStats: ChatMessageAgenticTurnStats = {
|
||||
turn: turn + 1,
|
||||
llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 },
|
||||
toolCalls: [],
|
||||
toolsMs: 0
|
||||
};
|
||||
|
||||
try {
|
||||
await ChatService.sendMessage(
|
||||
sessionMessages as ApiChatMessageData[],
|
||||
{
|
||||
...options,
|
||||
stream: true,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
onChunk: (chunk: string) => {
|
||||
turnContent += chunk;
|
||||
onChunk?.(chunk);
|
||||
},
|
||||
onReasoningChunk,
|
||||
onToolCallChunk: (serialized: string) => {
|
||||
try {
|
||||
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
|
||||
for (let i = 0; i < turnToolCalls.length; i++) {
|
||||
const toolCall = turnToolCalls[i];
|
||||
const toolName = toolCall.function?.name ?? '';
|
||||
const toolArgs = toolCall.function?.arguments ?? '';
|
||||
const state = emittedToolCallStates.get(i) || {
|
||||
emittedOnce: false,
|
||||
lastArgs: ''
|
||||
};
|
||||
if (!state.emittedOnce) {
|
||||
const output = `\n\n${AGENTIC_TAGS.TOOL_CALL_START}\n${AGENTIC_TAGS.TOOL_NAME_PREFIX}${toolName}${AGENTIC_TAGS.TAG_SUFFIX}\n${AGENTIC_TAGS.TOOL_ARGS_START}\n${toolArgs}`;
|
||||
onChunk?.(output);
|
||||
state.emittedOnce = true;
|
||||
state.lastArgs = toolArgs;
|
||||
emittedToolCallStates.set(i, state);
|
||||
} else if (toolArgs.length > state.lastArgs.length) {
|
||||
onChunk?.(toolArgs.slice(state.lastArgs.length));
|
||||
state.lastArgs = toolArgs;
|
||||
emittedToolCallStates.set(i, state);
|
||||
}
|
||||
}
|
||||
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
|
||||
const name = turnToolCalls[0].function.name || '';
|
||||
const args = turnToolCalls[0].function.arguments || '';
|
||||
const argsLengthBucket = Math.floor(args.length / 100);
|
||||
if (
|
||||
name !== lastStreamingToolCallName ||
|
||||
argsLengthBucket !== lastStreamingToolCallArgsLength
|
||||
) {
|
||||
lastStreamingToolCallName = name;
|
||||
lastStreamingToolCallArgsLength = argsLengthBucket;
|
||||
this.updateSession(conversationId, {
|
||||
streamingToolCall: { name, arguments: args }
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Ignore parse errors during streaming */
|
||||
}
|
||||
},
|
||||
onModel,
|
||||
onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => {
|
||||
onTimings?.(timings, progress);
|
||||
if (timings) {
|
||||
capturedTimings = timings;
|
||||
turnTimings = timings;
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
/* Completion handled after sendMessage resolves */
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
signal
|
||||
);
|
||||
|
||||
this.updateSession(conversationId, { streamingToolCall: null });
|
||||
|
||||
if (turnTimings) {
|
||||
agenticTimings.llm.predicted_n += turnTimings.predicted_n || 0;
|
||||
agenticTimings.llm.predicted_ms += turnTimings.predicted_ms || 0;
|
||||
agenticTimings.llm.prompt_n += turnTimings.prompt_n || 0;
|
||||
agenticTimings.llm.prompt_ms += turnTimings.prompt_ms || 0;
|
||||
turnStats.llm.predicted_n = turnTimings.predicted_n || 0;
|
||||
turnStats.llm.predicted_ms = turnTimings.predicted_ms || 0;
|
||||
turnStats.llm.prompt_n = turnTimings.prompt_n || 0;
|
||||
turnStats.llm.prompt_ms = turnTimings.prompt_ms || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
|
||||
onChunk?.(`${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`);
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
throw normalizedError;
|
||||
}
|
||||
|
||||
if (turnToolCalls.length === 0) {
|
||||
agenticTimings.perTurn!.push(turnStats);
|
||||
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
|
||||
if (normalizedCalls.length === 0) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const call of normalizedCalls) {
|
||||
allToolCalls.push({
|
||||
id: call.id,
|
||||
type: call.type,
|
||||
function: call.function ? { ...call.function } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
this.updateSession(conversationId, { totalToolCalls: allToolCalls.length });
|
||||
onToolCallChunk?.(JSON.stringify(allToolCalls));
|
||||
|
||||
sessionMessages.push({
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: turnContent || undefined,
|
||||
tool_calls: normalizedCalls
|
||||
});
|
||||
|
||||
for (const toolCall of normalizedCalls) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const toolStartTime = performance.now();
|
||||
const mcpCall: MCPToolCall = {
|
||||
id: toolCall.id,
|
||||
function: { name: toolCall.function.name, arguments: toolCall.function.arguments }
|
||||
};
|
||||
|
||||
let result: string;
|
||||
let toolSuccess = true;
|
||||
|
||||
try {
|
||||
const executionResult = await mcpStore.executeTool(mcpCall, signal);
|
||||
result = executionResult.content;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
toolSuccess = false;
|
||||
}
|
||||
|
||||
const toolDurationMs = performance.now() - toolStartTime;
|
||||
const toolTiming: ChatMessageToolCallTiming = {
|
||||
name: toolCall.function.name,
|
||||
duration_ms: Math.round(toolDurationMs),
|
||||
success: toolSuccess
|
||||
};
|
||||
|
||||
agenticTimings.toolCalls!.push(toolTiming);
|
||||
agenticTimings.toolCallsCount++;
|
||||
agenticTimings.toolsMs += Math.round(toolDurationMs);
|
||||
turnStats.toolCalls.push(toolTiming);
|
||||
turnStats.toolsMs += Math.round(toolDurationMs);
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { cleanedResult, attachments } = this.extractBase64Attachments(result);
|
||||
if (attachments.length > 0) onAttachments?.(attachments);
|
||||
|
||||
this.emitToolCallResult(cleanedResult, maxToolPreviewLines, onChunk);
|
||||
|
||||
const contentParts: ApiChatMessageContentPart[] = [
|
||||
{ type: ContentPartType.TEXT, text: cleanedResult }
|
||||
];
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.type === AttachmentType.IMAGE) {
|
||||
if (modelsStore.modelSupportsVision(options.model ?? '')) {
|
||||
contentParts.push({
|
||||
type: ContentPartType.IMAGE_URL,
|
||||
image_url: { url: (attachment as DatabaseMessageExtraImageFile).base64Url }
|
||||
});
|
||||
} else {
|
||||
console.info(
|
||||
`[AgenticStore] Skipping image attachment (model "${options.model}" does not support vision)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionMessages.push({
|
||||
role: MessageRole.TOOL,
|
||||
tool_call_id: toolCall.id,
|
||||
content: contentParts.length === 1 ? cleanedResult : contentParts
|
||||
});
|
||||
}
|
||||
|
||||
if (turnStats.toolCalls.length > 0) {
|
||||
agenticTimings.perTurn!.push(turnStats);
|
||||
|
||||
const intermediateTimings = this.buildFinalTimings(capturedTimings, agenticTimings);
|
||||
if (intermediateTimings) onTurnComplete?.(intermediateTimings);
|
||||
}
|
||||
}
|
||||
|
||||
onChunk?.(TURN_LIMIT_MESSAGE);
|
||||
onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
|
||||
}
|
||||
|
||||
private buildFinalTimings(
|
||||
capturedTimings: ChatMessageTimings | undefined,
|
||||
agenticTimings: ChatMessageAgenticTimings
|
||||
): ChatMessageTimings | undefined {
|
||||
if (agenticTimings.toolCallsCount === 0) return capturedTimings;
|
||||
return {
|
||||
predicted_n: capturedTimings?.predicted_n,
|
||||
predicted_ms: capturedTimings?.predicted_ms,
|
||||
prompt_n: capturedTimings?.prompt_n,
|
||||
prompt_ms: capturedTimings?.prompt_ms,
|
||||
cache_n: capturedTimings?.cache_n,
|
||||
agentic: agenticTimings
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
|
||||
if (!toolCalls) return [];
|
||||
return toolCalls.map((call, index) => ({
|
||||
id: call?.id ?? `tool_${index}`,
|
||||
type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION,
|
||||
function: { name: call?.function?.name ?? '', arguments: call?.function?.arguments ?? '' }
|
||||
}));
|
||||
}
|
||||
|
||||
private emitToolCallResult(
|
||||
result: string,
|
||||
maxLines: number,
|
||||
emit?: (chunk: string) => void
|
||||
): void {
|
||||
if (!emit) {
|
||||
return;
|
||||
}
|
||||
|
||||
let output = `${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_ARGS_END}`;
|
||||
const lines = result.split(NEWLINE_SEPARATOR);
|
||||
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
|
||||
|
||||
output += `${NEWLINE_SEPARATOR}${trimmedLines.join(NEWLINE_SEPARATOR)}${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_CALL_END}${NEWLINE_SEPARATOR}`;
|
||||
emit(output);
|
||||
}
|
||||
|
||||
private extractBase64Attachments(result: string): {
|
||||
cleanedResult: string;
|
||||
attachments: DatabaseMessageExtra[];
|
||||
} {
|
||||
if (!result.trim()) {
|
||||
return { cleanedResult: result, attachments: [] };
|
||||
}
|
||||
|
||||
const lines = result.split(NEWLINE_SEPARATOR);
|
||||
const attachments: DatabaseMessageExtra[] = [];
|
||||
let attachmentIndex = 0;
|
||||
|
||||
const cleanedLines = lines.map((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
const match = trimmedLine.match(DATA_URI_BASE64_REGEX);
|
||||
if (!match) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const mimeType = match[1].toLowerCase();
|
||||
const base64Data = match[2];
|
||||
|
||||
if (!base64Data) {
|
||||
return line;
|
||||
}
|
||||
|
||||
attachmentIndex += 1;
|
||||
const name = this.buildAttachmentName(mimeType, attachmentIndex);
|
||||
|
||||
if (mimeType.startsWith(MimeTypePrefix.IMAGE)) {
|
||||
attachments.push({ type: AttachmentType.IMAGE, name, base64Url: trimmedLine });
|
||||
|
||||
return `[Attachment saved: ${name}]`;
|
||||
}
|
||||
|
||||
return line;
|
||||
});
|
||||
|
||||
return { cleanedResult: cleanedLines.join(NEWLINE_SEPARATOR), attachments };
|
||||
}
|
||||
|
||||
private buildAttachmentName(mimeType: string, index: number): string {
|
||||
const extension = IMAGE_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_IMAGE_EXTENSION;
|
||||
|
||||
return `${MCP_ATTACHMENT_NAME_PREFIX}-${Date.now()}-${index}.${extension}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const agenticStore = new AgenticStore();
|
||||
|
||||
export function agenticIsRunning(conversationId: string) {
|
||||
return agenticStore.isRunning(conversationId);
|
||||
}
|
||||
|
||||
export function agenticCurrentTurn(conversationId: string) {
|
||||
return agenticStore.currentTurn(conversationId);
|
||||
}
|
||||
|
||||
export function agenticTotalToolCalls(conversationId: string) {
|
||||
return agenticStore.totalToolCalls(conversationId);
|
||||
}
|
||||
|
||||
export function agenticLastError(conversationId: string) {
|
||||
return agenticStore.lastError(conversationId);
|
||||
}
|
||||
|
||||
export function agenticStreamingToolCall(conversationId: string) {
|
||||
return agenticStore.streamingToolCall(conversationId);
|
||||
}
|
||||
|
||||
export function agenticIsAnyRunning() {
|
||||
return agenticStore.isAnyRunning;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { SvelteMap } from 'svelte/reactivity';
|
||||
import { DatabaseService, ChatService } from '$lib/services';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { agenticStore } from '$lib/stores/agentic.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
|
||||
import {
|
||||
selectedModelName,
|
||||
@@ -468,6 +470,10 @@ class ChatStore {
|
||||
const activeConv = conversationsStore.activeConversation;
|
||||
if (activeConv && this.isChatLoadingInternal(activeConv.id)) return;
|
||||
|
||||
// Consume MCP resource attachments - converts them to extras and clears the live store
|
||||
const resourceExtras = mcpStore.consumeResourceAttachmentsAsExtras();
|
||||
const allExtras = resourceExtras.length > 0 ? [...(extras || []), ...resourceExtras] : extras;
|
||||
|
||||
let isNewConversation = false;
|
||||
if (!activeConv) {
|
||||
await conversationsStore.createConversation();
|
||||
@@ -499,7 +505,7 @@ class ChatStore {
|
||||
content,
|
||||
MessageType.TEXT,
|
||||
parentIdForUserMessage ?? '-1',
|
||||
extras
|
||||
allExtras
|
||||
);
|
||||
if (isNewConversation && content)
|
||||
await conversationsStore.updateConversationName(currentConv.id, content.trim());
|
||||
@@ -626,6 +632,10 @@ class ChatStore {
|
||||
);
|
||||
},
|
||||
onModel: (modelName: string) => recordModel(modelName),
|
||||
onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
|
||||
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
|
||||
conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings });
|
||||
},
|
||||
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
|
||||
const tokensPerSecond =
|
||||
timings?.predicted_ms && timings?.predicted_n
|
||||
@@ -706,6 +716,20 @@ class ChatStore {
|
||||
if (onError) onError(error);
|
||||
}
|
||||
};
|
||||
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
|
||||
|
||||
const agenticConfig = agenticStore.getConfig(config(), perChatOverrides);
|
||||
if (agenticConfig.enabled) {
|
||||
const agenticResult = await agenticStore.runAgenticFlow({
|
||||
conversationId: assistantMessage.convId,
|
||||
messages: allMessages,
|
||||
options: { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) },
|
||||
callbacks: streamCallbacks,
|
||||
signal: abortController.signal,
|
||||
perChatOverrides
|
||||
});
|
||||
if (agenticResult.handled) return;
|
||||
}
|
||||
|
||||
const completionOptions = {
|
||||
...this.getApiOptions(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* conversationsStore - Reactive State Store for Conversations
|
||||
*
|
||||
* Manages conversation lifecycle, persistence, navigation.
|
||||
* Manages conversation lifecycle, persistence, navigation, and MCP server overrides.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **DatabaseService**: Stateless IndexedDB layer
|
||||
@@ -11,6 +11,7 @@
|
||||
* **Key Responsibilities:**
|
||||
* - Conversation CRUD (create, load, delete)
|
||||
* - Message management and tree navigation
|
||||
* - MCP server per-chat overrides
|
||||
* - Import/Export functionality
|
||||
* - Title management with confirmation
|
||||
*
|
||||
@@ -23,6 +24,7 @@ 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';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
class ConversationsStore {
|
||||
@@ -46,9 +48,20 @@ class ConversationsStore {
|
||||
/** Whether the store has been initialized */
|
||||
isInitialized = $state(false);
|
||||
|
||||
/** Pending MCP server overrides for new conversations (before first message) */
|
||||
pendingMcpServerOverrides = $state<McpServerOverride[]>([]);
|
||||
|
||||
/** Callback for title update confirmation dialog */
|
||||
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Callback for updating message content in chatStore.
|
||||
* Registered by chatStore to enable cross-store updates without circular dependency.
|
||||
*/
|
||||
private messageUpdateCallback:
|
||||
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
|
||||
| null = null;
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
@@ -80,6 +93,16 @@ class ConversationsStore {
|
||||
return this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for message updates from other stores.
|
||||
* Called by chatStore during initialization.
|
||||
*/
|
||||
registerMessageUpdateCallback(
|
||||
callback: (messageId: string, updates: Partial<DatabaseMessage>) => void
|
||||
): void {
|
||||
this.messageUpdateCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
@@ -162,6 +185,19 @@ class ConversationsStore {
|
||||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||
const conversation = await DatabaseService.createConversation(conversationName);
|
||||
|
||||
if (this.pendingMcpServerOverrides.length > 0) {
|
||||
// Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
|
||||
const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}));
|
||||
conversation.mcpServerOverrides = plainOverrides;
|
||||
await DatabaseService.updateConversation(conversation.id, {
|
||||
mcpServerOverrides: plainOverrides
|
||||
});
|
||||
this.pendingMcpServerOverrides = [];
|
||||
}
|
||||
|
||||
this.conversations = [conversation, ...this.conversations];
|
||||
this.activeConversation = conversation;
|
||||
this.activeMessages = [];
|
||||
@@ -184,6 +220,7 @@ class ConversationsStore {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pendingMcpServerOverrides = [];
|
||||
this.activeConversation = conversation;
|
||||
|
||||
if (conversation.currNode) {
|
||||
@@ -432,6 +469,148 @@ class ConversationsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* MCP Server Overrides
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets MCP server override for a specific server in the active conversation.
|
||||
* Falls back to pending overrides if no active conversation exists.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns The override if set, undefined if using global setting
|
||||
*/
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
if (this.activeConversation) {
|
||||
return this.activeConversation.mcpServerOverrides?.find(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
}
|
||||
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all MCP server overrides for the current conversation.
|
||||
* Returns pending overrides if no active conversation.
|
||||
*/
|
||||
getAllMcpServerOverrides(): McpServerOverride[] {
|
||||
if (this.activeConversation?.mcpServerOverrides) {
|
||||
return this.activeConversation.mcpServerOverrides;
|
||||
}
|
||||
return this.pendingMcpServerOverrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an MCP server is enabled for the active conversation.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns True if server is enabled for this conversation
|
||||
*/
|
||||
isMcpServerEnabledForChat(serverId: string): boolean {
|
||||
const override = this.getMcpServerOverride(serverId);
|
||||
return override?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes MCP server override for the active conversation.
|
||||
* If no conversation exists, stores as pending override.
|
||||
* @param serverId - The server ID to override
|
||||
* @param enabled - The enabled state, or undefined to remove override
|
||||
*/
|
||||
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
|
||||
if (!this.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) {
|
||||
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
|
||||
} else {
|
||||
const existingIndex = currentOverrides.findIndex(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
newOverrides = [...currentOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
} else {
|
||||
newOverrides = [...currentOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
|
||||
await DatabaseService.updateConversation(this.activeConversation.id, {
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
});
|
||||
|
||||
this.activeConversation = {
|
||||
...this.activeConversation,
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
};
|
||||
|
||||
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||
if (convIndex !== -1) {
|
||||
this.conversations[convIndex].mcpServerOverrides =
|
||||
newOverrides.length > 0 ? newOverrides : undefined;
|
||||
this.conversations = [...this.conversations];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes a pending MCP server override (for new conversations).
|
||||
*/
|
||||
private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
|
||||
if (enabled === undefined) {
|
||||
this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter(
|
||||
(o) => o.serverId !== serverId
|
||||
);
|
||||
} else {
|
||||
const existingIndex = this.pendingMcpServerOverrides.findIndex(
|
||||
(o) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const newOverrides = [...this.pendingMcpServerOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
this.pendingMcpServerOverrides = newOverrides;
|
||||
} else {
|
||||
this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles MCP server enabled state for the active conversation.
|
||||
* @param serverId - The server ID to toggle
|
||||
*/
|
||||
async toggleMcpServerForChat(serverId: string): Promise<void> {
|
||||
const currentEnabled = this.isMcpServerEnabledForChat(serverId);
|
||||
await this.setMcpServerOverride(serverId, !currentEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes MCP server override for the active conversation.
|
||||
* @param serverId - The server ID to remove override for
|
||||
*/
|
||||
async removeMcpServerOverride(serverId: string): Promise<void> {
|
||||
await this.setMcpServerOverride(serverId, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all pending MCP server overrides.
|
||||
*/
|
||||
clearPendingMcpServerOverrides(): void {
|
||||
this.pendingMcpServerOverrides = [];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* mcpResourceStore - Reactive State Store for MCP Resources
|
||||
*
|
||||
* Manages MCP protocol resources:
|
||||
* - Resource discovery and listing per server
|
||||
* - Resource content caching
|
||||
* - Resource subscriptions
|
||||
* - Resource attachments for chat context
|
||||
*
|
||||
* @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18/server/resources
|
||||
*/
|
||||
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import {
|
||||
MCP_RESOURCE_ATTACHMENT_ID_PREFIX,
|
||||
MCP_RESOURCE_CACHE_MAX_ENTRIES,
|
||||
MCP_RESOURCE_CACHE_TTL_MS,
|
||||
NEWLINE_SEPARATOR,
|
||||
RESOURCE_UNKNOWN_TYPE,
|
||||
BINARY_CONTENT_LABEL
|
||||
} from '$lib/constants';
|
||||
import { normalizeResourceUri } from '$lib/utils';
|
||||
import type {
|
||||
MCPResource,
|
||||
MCPResourceTemplate,
|
||||
MCPResourceContent,
|
||||
MCPResourceInfo,
|
||||
MCPResourceTemplateInfo,
|
||||
MCPCachedResource,
|
||||
MCPResourceAttachment,
|
||||
MCPResourceSubscription,
|
||||
MCPServerResources,
|
||||
DatabaseMessageExtraMcpResource
|
||||
} from '$lib/types';
|
||||
|
||||
function generateAttachmentId(): string {
|
||||
return `${MCP_RESOURCE_ATTACHMENT_ID_PREFIX}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
class MCPResourceStore {
|
||||
private _serverResources = $state<SvelteMap<string, MCPServerResources>>(new SvelteMap());
|
||||
private _cachedResources = $state<SvelteMap<string, MCPCachedResource>>(new SvelteMap());
|
||||
private _subscriptions = $state<SvelteMap<string, MCPResourceSubscription>>(new SvelteMap());
|
||||
private _attachments = $state<MCPResourceAttachment[]>([]);
|
||||
private _isLoading = $state(false);
|
||||
|
||||
get serverResources(): Map<string, MCPServerResources> {
|
||||
return this._serverResources;
|
||||
}
|
||||
|
||||
get cachedResources(): Map<string, MCPCachedResource> {
|
||||
return this._cachedResources;
|
||||
}
|
||||
|
||||
get subscriptions(): Map<string, MCPResourceSubscription> {
|
||||
return this._subscriptions;
|
||||
}
|
||||
|
||||
get attachments(): MCPResourceAttachment[] {
|
||||
return this._attachments;
|
||||
}
|
||||
|
||||
get isLoading(): boolean {
|
||||
return this._isLoading;
|
||||
}
|
||||
|
||||
get totalResourceCount(): number {
|
||||
let count = 0;
|
||||
for (const serverRes of this._serverResources.values()) {
|
||||
count += serverRes.resources.length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
get totalTemplateCount(): number {
|
||||
let count = 0;
|
||||
for (const serverRes of this._serverResources.values()) {
|
||||
count += serverRes.templates.length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
get attachmentCount(): number {
|
||||
return this._attachments.length;
|
||||
}
|
||||
|
||||
get hasAttachments(): boolean {
|
||||
return this._attachments.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Server Resources Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set resources for a server (called after listResources)
|
||||
*/
|
||||
setServerResources(
|
||||
serverName: string,
|
||||
resources: MCPResource[],
|
||||
templates: MCPResourceTemplate[]
|
||||
): void {
|
||||
this._serverResources.set(serverName, {
|
||||
serverName,
|
||||
resources,
|
||||
templates,
|
||||
lastFetched: new Date(),
|
||||
loading: false,
|
||||
error: undefined
|
||||
});
|
||||
console.log(
|
||||
`[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading state for a server's resources
|
||||
*/
|
||||
setServerLoading(serverName: string, loading: boolean): void {
|
||||
const existing = this._serverResources.get(serverName);
|
||||
if (existing) {
|
||||
this._serverResources.set(serverName, { ...existing, loading });
|
||||
} else {
|
||||
this._serverResources.set(serverName, {
|
||||
serverName,
|
||||
resources: [],
|
||||
templates: [],
|
||||
loading,
|
||||
error: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error state for a server's resources
|
||||
*/
|
||||
setServerError(serverName: string, error: string): void {
|
||||
const existing = this._serverResources.get(serverName);
|
||||
|
||||
if (existing) {
|
||||
this._serverResources.set(serverName, { ...existing, loading: false, error });
|
||||
} else {
|
||||
this._serverResources.set(serverName, {
|
||||
serverName,
|
||||
resources: [],
|
||||
templates: [],
|
||||
loading: false,
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resources for a specific server
|
||||
*/
|
||||
getServerResources(serverName: string): MCPServerResources | undefined {
|
||||
return this._serverResources.get(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all resources as MCPResourceInfo array (flattened with server names)
|
||||
*/
|
||||
getAllResourceInfos(): MCPResourceInfo[] {
|
||||
const result: MCPResourceInfo[] = [];
|
||||
|
||||
for (const [serverName, serverRes] of this._serverResources) {
|
||||
for (const resource of serverRes.resources) {
|
||||
result.push({
|
||||
uri: resource.uri,
|
||||
name: resource.name,
|
||||
title: resource.title,
|
||||
description: resource.description,
|
||||
mimeType: resource.mimeType,
|
||||
serverName,
|
||||
annotations: resource.annotations,
|
||||
icons: resource.icons
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all templates as MCPResourceTemplateInfo array (flattened with server names)
|
||||
*/
|
||||
getAllTemplateInfos(): MCPResourceTemplateInfo[] {
|
||||
const result: MCPResourceTemplateInfo[] = [];
|
||||
|
||||
for (const [serverName, serverRes] of this._serverResources) {
|
||||
for (const template of serverRes.templates) {
|
||||
result.push({
|
||||
uriTemplate: template.uriTemplate,
|
||||
name: template.name,
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
mimeType: template.mimeType,
|
||||
serverName,
|
||||
annotations: template.annotations,
|
||||
icons: template.icons
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear resources for a server (e.g., when disconnected)
|
||||
*/
|
||||
clearServerResources(serverName: string): void {
|
||||
this._serverResources.delete(serverName);
|
||||
|
||||
// Also clear cached content for this server's resources
|
||||
for (const [uri, cached] of this._cachedResources) {
|
||||
if (cached.resource.serverName === serverName) {
|
||||
this._cachedResources.delete(uri);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear subscriptions for this server
|
||||
for (const [uri, sub] of this._subscriptions) {
|
||||
if (sub.serverName === serverName) {
|
||||
this._subscriptions.delete(uri);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[MCPResources][${serverName}] Cleared all resources`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Resource Content Caching
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cache resource content after reading
|
||||
*/
|
||||
cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void {
|
||||
// Enforce cache size limit
|
||||
if (this._cachedResources.size >= MCP_RESOURCE_CACHE_MAX_ENTRIES) {
|
||||
// Remove oldest entry
|
||||
const oldestKey = this._cachedResources.keys().next().value;
|
||||
|
||||
if (oldestKey) {
|
||||
this._cachedResources.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
this._cachedResources.set(resource.uri, {
|
||||
resource,
|
||||
content,
|
||||
fetchedAt: new Date(),
|
||||
subscribed: this._subscriptions.has(resource.uri)
|
||||
});
|
||||
console.log(`[MCPResources] Cached content for: ${resource.uri}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached content for a resource
|
||||
*/
|
||||
getCachedContent(uri: string): MCPCachedResource | undefined {
|
||||
const cached = this._cachedResources.get(uri);
|
||||
if (!cached) return undefined;
|
||||
|
||||
// Check if cache is still valid
|
||||
const age = Date.now() - cached.fetchedAt.getTime();
|
||||
|
||||
if (age > MCP_RESOURCE_CACHE_TTL_MS && !cached.subscribed) {
|
||||
// Cache expired and not subscribed, remove it
|
||||
this._cachedResources.delete(uri);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached content for a resource (e.g., on update notification)
|
||||
*/
|
||||
invalidateCache(uri: string): void {
|
||||
this._cachedResources.delete(uri);
|
||||
console.log(`[MCPResources] Invalidated cache for: ${uri}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached content
|
||||
*/
|
||||
clearCache(): void {
|
||||
this._cachedResources.clear();
|
||||
console.log(`[MCPResources] Cleared all cached content`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Subscriptions
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register a subscription for a resource
|
||||
*/
|
||||
addSubscription(uri: string, serverName: string): void {
|
||||
this._subscriptions.set(uri, {
|
||||
uri,
|
||||
serverName,
|
||||
subscribedAt: new Date()
|
||||
});
|
||||
|
||||
// Update cached resource if exists
|
||||
const cached = this._cachedResources.get(uri);
|
||||
if (cached) {
|
||||
this._cachedResources.set(uri, { ...cached, subscribed: true });
|
||||
}
|
||||
|
||||
console.log(`[MCPResources] Added subscription: ${uri}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subscription for a resource
|
||||
*/
|
||||
removeSubscription(uri: string): void {
|
||||
this._subscriptions.delete(uri);
|
||||
|
||||
// Update cached resource if exists
|
||||
const cached = this._cachedResources.get(uri);
|
||||
if (cached) {
|
||||
this._cachedResources.set(uri, { ...cached, subscribed: false });
|
||||
}
|
||||
|
||||
console.log(`[MCPResources] Removed subscription: ${uri}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a resource is subscribed
|
||||
*/
|
||||
isSubscribed(uri: string): boolean {
|
||||
return this._subscriptions.has(uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resource update notification
|
||||
*/
|
||||
handleResourceUpdate(uri: string): void {
|
||||
// Invalidate cache so next read gets fresh content
|
||||
this.invalidateCache(uri);
|
||||
|
||||
// Update subscription last update time
|
||||
const sub = this._subscriptions.get(uri);
|
||||
if (sub) {
|
||||
this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() });
|
||||
}
|
||||
|
||||
console.log(`[MCPResources] Resource updated: ${uri}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resources list changed notification
|
||||
*/
|
||||
handleResourcesListChanged(serverName: string): void {
|
||||
// Mark server resources as needing refresh
|
||||
const existing = this._serverResources.get(serverName);
|
||||
if (existing) {
|
||||
this._serverResources.set(serverName, {
|
||||
...existing,
|
||||
lastFetched: undefined // Mark as stale
|
||||
});
|
||||
}
|
||||
console.log(`[MCPResources][${serverName}] Resources list changed, needs refresh`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Attachments (for chat context)
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add a resource attachment to the current chat context
|
||||
*/
|
||||
addAttachment(resource: MCPResourceInfo): MCPResourceAttachment {
|
||||
const attachment: MCPResourceAttachment = {
|
||||
id: generateAttachmentId(),
|
||||
resource,
|
||||
loading: true
|
||||
};
|
||||
|
||||
this._attachments = [...this._attachments, attachment];
|
||||
console.log(`[MCPResources] Added attachment: ${resource.uri}`);
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update attachment with fetched content
|
||||
*/
|
||||
updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void {
|
||||
this._attachments = this._attachments.map((att) =>
|
||||
att.id === attachmentId ? { ...att, content, loading: false, error: undefined } : att
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update attachment with error
|
||||
*/
|
||||
updateAttachmentError(attachmentId: string, error: string): void {
|
||||
this._attachments = this._attachments.map((att) =>
|
||||
att.id === attachmentId ? { ...att, loading: false, error } : att
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an attachment
|
||||
*/
|
||||
removeAttachment(attachmentId: string): void {
|
||||
this._attachments = this._attachments.filter((att) => att.id !== attachmentId);
|
||||
console.log(`[MCPResources] Removed attachment: ${attachmentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all attachments
|
||||
*/
|
||||
clearAttachments(): void {
|
||||
this._attachments = [];
|
||||
console.log(`[MCPResources] Cleared all attachments`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachment by ID
|
||||
*/
|
||||
getAttachment(attachmentId: string): MCPResourceAttachment | undefined {
|
||||
return this._attachments.find((att) => att.id === attachmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a resource is already attached
|
||||
*/
|
||||
isAttached(uri: string): boolean {
|
||||
const normalizedUri = normalizeResourceUri(uri);
|
||||
|
||||
return this._attachments.some(
|
||||
(att) => att.resource.uri === uri || normalizeResourceUri(att.resource.uri) === normalizedUri
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Utility Methods
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set global loading state
|
||||
*/
|
||||
setLoading(loading: boolean): void {
|
||||
this._isLoading = loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find resource info by URI across all servers
|
||||
*/
|
||||
findResourceByUri(uri: string): MCPResourceInfo | undefined {
|
||||
const normalizedUri = normalizeResourceUri(uri);
|
||||
|
||||
for (const [serverName, serverRes] of this._serverResources) {
|
||||
const resource =
|
||||
serverRes.resources.find((r) => r.uri === uri) ??
|
||||
serverRes.resources.find((r) => normalizeResourceUri(r.uri) === normalizedUri);
|
||||
|
||||
if (resource) {
|
||||
return {
|
||||
uri: resource.uri,
|
||||
name: resource.name,
|
||||
title: resource.title,
|
||||
description: resource.description,
|
||||
mimeType: resource.mimeType,
|
||||
serverName,
|
||||
annotations: resource.annotations,
|
||||
icons: resource.icons
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find server name for a resource URI
|
||||
*/
|
||||
findServerForUri(uri: string): string | undefined {
|
||||
for (const [serverName, serverRes] of this._serverResources) {
|
||||
if (serverRes.resources.some((r) => r.uri === uri)) {
|
||||
return serverName;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all state (e.g., on full reset)
|
||||
*/
|
||||
clear(): void {
|
||||
this._serverResources.clear();
|
||||
this._cachedResources.clear();
|
||||
this._subscriptions.clear();
|
||||
this._attachments = [];
|
||||
this._isLoading = false;
|
||||
console.log(`[MCPResources] Cleared all state`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource content as text for chat context
|
||||
* Formats content for inclusion in LLM prompts
|
||||
*/
|
||||
formatAttachmentsForContext(): string {
|
||||
if (this._attachments.length === 0) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const attachment of this._attachments) {
|
||||
if (attachment.error) continue;
|
||||
if (!attachment.content || attachment.content.length === 0) continue;
|
||||
|
||||
const resourceName = attachment.resource.title || attachment.resource.name;
|
||||
const serverName = attachment.resource.serverName;
|
||||
|
||||
for (const content of attachment.content) {
|
||||
if ('text' in content && content.text) {
|
||||
parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`);
|
||||
} else if ('blob' in content && content.blob) {
|
||||
// For binary content, just note it exists
|
||||
parts.push(
|
||||
`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert current resource attachments to DatabaseMessageExtra[] for persisting with a message.
|
||||
* Each attachment becomes a DatabaseMessageExtraMcpResource stored on the user message.
|
||||
*/
|
||||
toMessageExtras(): DatabaseMessageExtraMcpResource[] {
|
||||
const extras: DatabaseMessageExtraMcpResource[] = [];
|
||||
|
||||
for (const attachment of this._attachments) {
|
||||
if (attachment.error) continue;
|
||||
if (!attachment.content || attachment.content.length === 0) continue;
|
||||
|
||||
const resourceName = attachment.resource.title || attachment.resource.name;
|
||||
const contentParts: string[] = [];
|
||||
|
||||
for (const content of attachment.content) {
|
||||
if ('text' in content && content.text) {
|
||||
contentParts.push(content.text);
|
||||
} else if ('blob' in content && content.blob) {
|
||||
contentParts.push(
|
||||
`[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentParts.length > 0) {
|
||||
extras.push({
|
||||
type: AttachmentType.MCP_RESOURCE,
|
||||
name: resourceName,
|
||||
uri: attachment.resource.uri,
|
||||
serverName: attachment.resource.serverName,
|
||||
content: contentParts.join(NEWLINE_SEPARATOR),
|
||||
mimeType: attachment.resource.mimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return extras;
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpResourceStore = new MCPResourceStore();
|
||||
|
||||
// Export convenience functions
|
||||
export const mcpResources = () => mcpResourceStore.serverResources;
|
||||
export const mcpResourceAttachments = () => mcpResourceStore.attachments;
|
||||
export const mcpResourceAttachmentCount = () => mcpResourceStore.attachmentCount;
|
||||
export const mcpHasResourceAttachments = () => mcpResourceStore.hasAttachments;
|
||||
export const mcpTotalResourceCount = () => mcpResourceStore.totalResourceCount;
|
||||
export const mcpResourcesLoading = () => mcpResourceStore.isLoading;
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user