From 6018f85c6514fb0a5d6958ce7f1bd2b63b4cd829 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 22 Jan 2026 18:11:53 +0100 Subject: [PATCH] feat: Architectural improvements --- .../webui/src/lib/clients/agentic.client.ts | 12 +- .../webui/src/lib/clients/chat.client.ts | 15 +- .../webui/src/lib/clients/mcp.client.ts | 115 +++++++++++- .../chat/ChatMessages/AgenticContent.svelte | 3 + .../ChatMessages/ChatMessageAssistant.svelte | 4 +- .../CollapsibleContentBlock.svelte | 25 +++ .../app/chat/ChatScreen/ChatScreen.svelte | 90 ++-------- .../src/lib/hooks/use-auto-scroll.svelte.ts | 165 ++++++++++++++++++ .../webui/src/lib/services/mcp.service.ts | 19 +- .../webui/src/lib/stores/chat.svelte.ts | 11 ++ tools/server/webui/src/lib/types/index.ts | 3 +- tools/server/webui/src/lib/utils/debounce.ts | 22 +++ 12 files changed, 399 insertions(+), 85 deletions(-) create mode 100644 tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts create mode 100644 tools/server/webui/src/lib/utils/debounce.ts diff --git a/tools/server/webui/src/lib/clients/agentic.client.ts b/tools/server/webui/src/lib/clients/agentic.client.ts index 0ef242e9a6..d35ebaa5c5 100644 --- a/tools/server/webui/src/lib/clients/agentic.client.ts +++ b/tools/server/webui/src/lib/clients/agentic.client.ts @@ -193,6 +193,9 @@ export class AgenticClient { this.store.setTotalToolCalls(conversationId, 0); this.store.setLastError(conversationId, null); + // Acquire reference to prevent premature shutdown while this flow is active + mcpClient.acquireConnection(); + try { await this.executeAgenticLoop({ conversationId, @@ -220,13 +223,10 @@ export class AgenticClient { return { handled: true, error: normalizedError }; } finally { this.store.setRunning(conversationId, 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); + // Release reference - will only shutdown if no other flows are active + await mcpClient.releaseConnection().catch((err) => { + console.warn('[AgenticClient] Failed to release MCP connection:', err); }); - - console.log('[AgenticClient] MCP connections closed (lazy disconnect)'); } } diff --git a/tools/server/webui/src/lib/clients/chat.client.ts b/tools/server/webui/src/lib/clients/chat.client.ts index ca27c1fb38..4c22b20b48 100644 --- a/tools/server/webui/src/lib/clients/chat.client.ts +++ b/tools/server/webui/src/lib/clients/chat.client.ts @@ -421,6 +421,8 @@ export class ChatClient { this.store.clearChatStreaming(currentConv.id); try { + let parentIdForUserMessage: string | undefined; + if (isNewConversation) { const rootId = await DatabaseService.createRootMessage(currentConv.id); const currentConfig = config(); @@ -433,10 +435,20 @@ export class ChatClient { rootId ); conversationsStore.addMessageToActive(systemMessage); + parentIdForUserMessage = systemMessage.id; + } else { + // No system prompt - user message should be child of root + parentIdForUserMessage = rootId; } } - const userMessage = await this.addMessage('user', content, 'text', '-1', extras); + const userMessage = await this.addMessage( + 'user', + content, + 'text', + parentIdForUserMessage ?? '-1', + extras + ); if (!userMessage) throw new Error('Failed to add user message'); if (isNewConversation && content) await conversationsStore.updateConversationName(currentConv.id, content.trim()); @@ -680,6 +692,7 @@ export class ChatClient { const agenticConfig = getAgenticConfig(config(), perChatOverrides); if (agenticConfig.enabled) { const agenticResult = await agenticClient.runAgenticFlow({ + conversationId: assistantMessage.convId, messages: allMessages, options: { ...this.getApiOptions(), diff --git a/tools/server/webui/src/lib/clients/mcp.client.ts b/tools/server/webui/src/lib/clients/mcp.client.ts index c89ac7f65a..41b53f5576 100644 --- a/tools/server/webui/src/lib/clients/mcp.client.ts +++ b/tools/server/webui/src/lib/clients/mcp.client.ts @@ -39,8 +39,12 @@ import type { ServerCapabilities, ClientCapabilities, MCPCapabilitiesInfo, - MCPConnectionLog + MCPConnectionLog, + Tool, } from '$lib/types'; +import type { + ListChangedHandlers, +} from '@modelcontextprotocol/sdk/types.js'; import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums'; import type { McpServerOverride } from '$lib/types/database'; import { MCPError } from '$lib/errors'; @@ -88,6 +92,13 @@ export class MCPClient { private toolsIndex = new Map(); private configSignature: string | null = null; private initPromise: Promise | null = null; + + /** + * Reference counter for active agentic flows using MCP connections. + * Prevents shutdown while any conversation is still using connections. + */ + private activeFlowCount = 0; + /** * Ensures MCP is initialized with current config. * Handles config changes by reinitializing as needed. @@ -136,6 +147,7 @@ export class MCPClient { } this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries); + return this.initPromise; } @@ -149,7 +161,16 @@ export class MCPClient { const results = await Promise.allSettled( serverEntries.map(async ([name, serverConfig]) => { - const connection = await MCPService.connect(name, serverConfig, clientInfo, capabilities); + const listChangedHandlers = this.createListChangedHandlers(name); + const connection = await MCPService.connect( + name, + serverConfig, + clientInfo, + capabilities, + undefined, + listChangedHandlers, + ); + return { name, connection }; }) ); @@ -161,6 +182,7 @@ export class MCPClient { await MCPService.disconnect(result.value.connection).catch(console.warn); } } + return false; } @@ -215,8 +237,95 @@ export class MCPClient { return true; } + /** + * Create list changed handlers for a server connection. + * These handlers are called when the server notifies about changes to tools, prompts, or resources. + */ + private createListChangedHandlers(serverName: string): ListChangedHandlers { + return { + tools: { + onChanged: (error: Error | null, tools: Tool[] | null) => { + if (error) { + console.warn(`[MCPClient][${serverName}] Tools list changed error:`, error); + return; + } + console.log(`[MCPClient][${serverName}] Tools list changed, ${tools?.length ?? 0} tools`); + this.handleToolsListChanged(serverName, tools ?? []); + } + }, + }; + } + + /** + * Handle tools list changed notification from a server. + * Updates the tools index and store. + */ + private handleToolsListChanged(serverName: string, tools: Tool[]): void { + const connection = this.connections.get(serverName); + if (!connection) return; + + // Remove old tools from this server from the index + for (const [toolName, ownerServer] of this.toolsIndex.entries()) { + if (ownerServer === serverName) { + this.toolsIndex.delete(toolName); + } + } + + // Update connection tools + connection.tools = tools; + + // Add new tools to the index + for (const tool of tools) { + if (this.toolsIndex.has(tool.name)) { + console.warn( + `[MCPClient] Tool name conflict after list change: "${tool.name}" exists in ` + + `"${this.toolsIndex.get(tool.name)}" and "${serverName}". ` + + `Using tool from "${serverName}".` + ); + } + this.toolsIndex.set(tool.name, serverName); + } + + // Update store + mcpStore.updateState({ + toolCount: this.toolsIndex.size + }); + } + + /** + * Acquire a reference to MCP connections for an agentic flow. + * Call this when starting an agentic flow to prevent premature shutdown. + */ + acquireConnection(): void { + this.activeFlowCount++; + console.log(`[MCPClient] Connection acquired (active flows: ${this.activeFlowCount})`); + } + + /** + * Release a reference to MCP connections. + * Call this when an agentic flow completes. + * @param shutdownIfUnused - If true, shutdown connections when no flows are active + */ + async releaseConnection(shutdownIfUnused = true): Promise { + this.activeFlowCount = Math.max(0, this.activeFlowCount - 1); + console.log(`[MCPClient] Connection released (active flows: ${this.activeFlowCount})`); + + if (shutdownIfUnused && this.activeFlowCount === 0) { + console.log('[MCPClient] No active flows, initiating lazy disconnect...'); + await this.shutdown(); + } + } + + /** + * Get the number of active agentic flows using MCP connections. + */ + getActiveFlowCount(): number { + return this.activeFlowCount; + } + /** * Shutdown all MCP connections and clear state. + * Note: This will force shutdown regardless of active flow count. */ async shutdown(): Promise { if (this.initPromise) { @@ -411,6 +520,7 @@ export class MCPClient { mcpStore.incrementServerUsage(serverName); const args = this.parseToolArguments(toolCall.function.arguments); + return MCPService.callTool(connection, { name: toolName, arguments: args }, signal); } @@ -491,6 +601,7 @@ export class MCPClient { } catch { console.warn('[MCPClient] Failed to parse custom headers JSON:', headersJson); } + return undefined; } diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte index f11efa8e0d..fdd4d0c6c2 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte @@ -292,6 +292,7 @@ iconClass={streamingIconClass} title={section.toolName || 'Tool call'} subtitle={streamingSubtitle} + {isStreaming} onToggle={() => toggleExpanded(index, section)} >
@@ -332,6 +333,7 @@ iconClass={toolIconClass} title={section.toolName || ''} subtitle={isPending ? 'executing...' : undefined} + isStreaming={isPending} onToggle={() => toggleExpanded(index, section)} > {#if section.toolArgs && section.toolArgs !== '{}'} @@ -388,6 +390,7 @@ icon={Brain} title={reasoningTitle} subtitle={reasoningSubtitle} + {isStreaming} onToggle={() => toggleExpanded(index, section)} >
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte index 2cc13fcf1b..239dc76314 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte @@ -84,7 +84,9 @@ const hasAgenticMarkers = $derived( messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false ); - const hasStreamingToolCall = $derived(isChatStreaming() && agenticStreamingToolCall() !== null); + const hasStreamingToolCall = $derived( + isChatStreaming() && agenticStreamingToolCall(message.convId) !== null + ); const hasReasoningMarkers = $derived(messageContent?.includes(REASONING_TAGS.START) ?? false); const isStructuredContent = $derived( hasAgenticMarkers || hasReasoningMarkers || hasStreamingToolCall diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/CollapsibleContentBlock.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/CollapsibleContentBlock.svelte index 9e4eecce3f..21fddf83d2 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/CollapsibleContentBlock.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/CollapsibleContentBlock.svelte @@ -4,11 +4,15 @@ * * Used for displaying thinking content, tool calls, and other collapsible information * with a consistent UI pattern. + * + * Features auto-scroll during streaming: scrolls to bottom automatically, + * stops when user scrolls up, resumes when user scrolls back to bottom. */ import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import { buttonVariants } from '$lib/components/ui/button/index.js'; import { Card } from '$lib/components/ui/card'; + import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; import type { Snippet } from 'svelte'; import type { Component } from 'svelte'; @@ -25,6 +29,8 @@ title: string; /** Optional subtitle/status text */ subtitle?: string; + /** Whether content is currently streaming (enables auto-scroll) */ + isStreaming?: boolean; /** Optional click handler for the trigger */ onToggle?: () => void; /** Content to display in the collapsible section */ @@ -38,9 +44,26 @@ iconClass = 'h-4 w-4', title, subtitle, + isStreaming = false, onToggle, children }: Props = $props(); + + let contentContainer: HTMLDivElement | undefined = $state(); + const autoScroll = createAutoScrollController(); + + $effect(() => { + autoScroll.setContainer(contentContainer); + }); + + $effect(() => { + // Only auto-scroll when open and streaming + autoScroll.updateInterval(open && isStreaming); + }); + + function handleScroll() { + autoScroll.handleScroll(); + }
{@render children()} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte index ed0869f3cc..6bf3d7714b 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte @@ -12,11 +12,8 @@ } from '$lib/components/app'; import * as Alert from '$lib/components/ui/alert'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; - import { - AUTO_SCROLL_AT_BOTTOM_THRESHOLD, - AUTO_SCROLL_INTERVAL, - INITIAL_SCROLL_DELAY - } from '$lib/constants/auto-scroll'; + import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll'; + import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; import { chatStore, errorDialog, @@ -42,16 +39,13 @@ let { showCenteredEmpty = false } = $props(); let disableAutoScroll = $derived(Boolean(config().disableAutoScroll)); - let autoScrollEnabled = $state(true); let chatScrollContainer: HTMLDivElement | undefined = $state(); let dragCounter = $state(0); let isDragOver = $state(false); - let lastScrollTop = $state(0); - let scrollInterval: ReturnType | undefined; - let scrollTimeout: ReturnType | undefined; let showFileErrorDialog = $state(false); let uploadedFiles = $state([]); - let userScrolledUp = $state(false); + + const autoScroll = createAutoScrollController(); let fileErrorData = $state<{ generallyUnsupported: File[]; @@ -222,32 +216,7 @@ } function handleScroll() { - if (disableAutoScroll || !chatScrollContainer) return; - - const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD; - - if (scrollTop < lastScrollTop && !isAtBottom) { - userScrolledUp = true; - autoScrollEnabled = false; - } else if (isAtBottom && userScrolledUp) { - userScrolledUp = false; - autoScrollEnabled = true; - } - - if (scrollTimeout) { - clearTimeout(scrollTimeout); - } - - scrollTimeout = setTimeout(() => { - if (isAtBottom) { - userScrolledUp = false; - autoScrollEnabled = true; - } - }, AUTO_SCROLL_INTERVAL); - - lastScrollTop = scrollTop; + autoScroll.handleScroll(); } async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise { @@ -269,12 +238,9 @@ const extras = result?.extras; // Enable autoscroll for user-initiated message sending - if (!disableAutoScroll) { - userScrolledUp = false; - autoScrollEnabled = true; - } + autoScroll.enable(); await chatStore.sendMessage(message, extras); - scrollChatToBottom(); + autoScroll.scrollToBottom(); return true; } @@ -324,43 +290,28 @@ } } - function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') { - if (disableAutoScroll) return; - - chatScrollContainer?.scrollTo({ - top: chatScrollContainer?.scrollHeight, - behavior - }); - } - afterNavigate(() => { if (!disableAutoScroll) { - setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY); + setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY); } }); onMount(() => { if (!disableAutoScroll) { - setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY); + setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY); } }); $effect(() => { - if (disableAutoScroll) { - autoScrollEnabled = false; - if (scrollInterval) { - clearInterval(scrollInterval); - scrollInterval = undefined; - } - return; - } + autoScroll.setContainer(chatScrollContainer); + }); - if (isCurrentConversationLoading && autoScrollEnabled) { - scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL); - } else if (scrollInterval) { - clearInterval(scrollInterval); - scrollInterval = undefined; - } + $effect(() => { + autoScroll.setDisabled(disableAutoScroll); + }); + + $effect(() => { + autoScroll.updateInterval(isCurrentConversationLoading); }); @@ -388,11 +339,8 @@ class="mb-16 md:mb-24" messages={activeMessages()} onUserAction={() => { - if (!disableAutoScroll) { - userScrolledUp = false; - autoScrollEnabled = true; - scrollChatToBottom(); - } + autoScroll.enable(); + autoScroll.scrollToBottom(); }} /> diff --git a/tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts b/tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts new file mode 100644 index 0000000000..bbaa5d1362 --- /dev/null +++ b/tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts @@ -0,0 +1,165 @@ +import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants/auto-scroll'; + +export interface AutoScrollOptions { + /** Whether auto-scroll is disabled globally (e.g., from settings) */ + disabled?: boolean; +} + +/** + * Creates an auto-scroll controller for a scrollable container. + * + * Features: + * - Auto-scrolls to bottom during streaming/loading + * - Stops auto-scroll when user manually scrolls up + * - Resumes auto-scroll when user scrolls back to bottom + */ +export class AutoScrollController { + private _autoScrollEnabled = $state(true); + private _userScrolledUp = $state(false); + private _lastScrollTop = $state(0); + private _scrollInterval: ReturnType | undefined; + private _scrollTimeout: ReturnType | undefined; + private _container: HTMLElement | undefined; + private _disabled: boolean; + + constructor(options: AutoScrollOptions = {}) { + this._disabled = options.disabled ?? false; + } + + get autoScrollEnabled(): boolean { + return this._autoScrollEnabled; + } + + get userScrolledUp(): boolean { + return this._userScrolledUp; + } + + /** + * Binds the controller to a scrollable container element. + */ + setContainer(container: HTMLElement | undefined): void { + this._container = container; + } + + /** + * Updates the disabled state. + */ + setDisabled(disabled: boolean): void { + this._disabled = disabled; + if (disabled) { + this._autoScrollEnabled = false; + this.stopInterval(); + } + } + + /** + * Handles scroll events to detect user scroll direction and toggle auto-scroll. + */ + handleScroll(): void { + if (this._disabled || !this._container) return; + + const { scrollTop, scrollHeight, clientHeight } = this._container; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD; + + if (scrollTop < this._lastScrollTop && !isAtBottom) { + this._userScrolledUp = true; + this._autoScrollEnabled = false; + } else if (isAtBottom && this._userScrolledUp) { + this._userScrolledUp = false; + this._autoScrollEnabled = true; + } + + if (this._scrollTimeout) { + clearTimeout(this._scrollTimeout); + } + + this._scrollTimeout = setTimeout(() => { + if (isAtBottom) { + this._userScrolledUp = false; + this._autoScrollEnabled = true; + } + }, AUTO_SCROLL_INTERVAL); + + this._lastScrollTop = scrollTop; + } + + /** + * Scrolls the container to the bottom. + */ + scrollToBottom(behavior: ScrollBehavior = 'smooth'): void { + if (this._disabled || !this._container) return; + + this._container.scrollTo({ + top: this._container.scrollHeight, + behavior + }); + } + + /** + * Enables auto-scroll (e.g., when user sends a message). + */ + enable(): void { + if (this._disabled) return; + this._userScrolledUp = false; + this._autoScrollEnabled = true; + } + + /** + * Starts the auto-scroll interval for continuous scrolling during streaming. + */ + startInterval(): void { + if (this._disabled || this._scrollInterval) return; + + this._scrollInterval = setInterval(() => { + this.scrollToBottom(); + }, AUTO_SCROLL_INTERVAL); + } + + /** + * Stops the auto-scroll interval. + */ + stopInterval(): void { + if (this._scrollInterval) { + clearInterval(this._scrollInterval); + this._scrollInterval = undefined; + } + } + + /** + * Updates the auto-scroll interval based on streaming state. + * Call this in a $effect to automatically manage the interval. + */ + updateInterval(isStreaming: boolean): void { + if (this._disabled) { + this.stopInterval(); + return; + } + + if (isStreaming && this._autoScrollEnabled) { + if (!this._scrollInterval) { + this.startInterval(); + } + } else { + this.stopInterval(); + } + } + + /** + * Cleans up resources. Call this in onDestroy or when the component unmounts. + */ + destroy(): void { + this.stopInterval(); + if (this._scrollTimeout) { + clearTimeout(this._scrollTimeout); + this._scrollTimeout = undefined; + } + } +} + +/** + * Creates a new AutoScrollController instance. + */ +export function createAutoScrollController(options: AutoScrollOptions = {}): AutoScrollController { + return new AutoScrollController(options); +} diff --git a/tools/server/webui/src/lib/services/mcp.service.ts b/tools/server/webui/src/lib/services/mcp.service.ts index 42500cdf76..8f0a7cd9e3 100644 --- a/tools/server/webui/src/lib/services/mcp.service.ts +++ b/tools/server/webui/src/lib/services/mcp.service.ts @@ -17,7 +17,10 @@ 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 { + Tool, + ListChangedHandlers, +} from '@modelcontextprotocol/sdk/types.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { MCPServerConfig, @@ -155,7 +158,8 @@ export class MCPService { serverConfig: MCPServerConfig, clientInfo?: Implementation, capabilities?: ClientCapabilities, - onPhase?: MCPPhaseCallback + onPhase?: MCPPhaseCallback, + listChangedHandlers?: ListChangedHandlers, ): Promise { const startTime = performance.now(); const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; @@ -185,7 +189,10 @@ export class MCPService { name: effectiveClientInfo.name, version: effectiveClientInfo.version ?? '1.0.0' }, - { capabilities: effectiveCapabilities } + { + capabilities: effectiveCapabilities, + listChanged: listChangedHandlers + } ); // Phase: Initializing @@ -320,7 +327,9 @@ export class MCPService { 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); } } @@ -342,18 +351,22 @@ export class MCPService { 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); } } diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index fe28ca3197..7f26f30953 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -97,6 +97,17 @@ class ChatStore { this.isLoading = this.chatLoadingStates.get(convId) || false; const streamingState = this.chatStreamingStates.get(convId); this.currentResponse = streamingState?.response || ''; + + // If there's an active stream for this conversation, update the message content + // This ensures streaming content is visible when switching back to a conversation + if (streamingState?.response && streamingState?.messageId) { + import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => { + const idx = conversationsStore.findMessageIndex(streamingState.messageId); + if (idx !== -1) { + conversationsStore.updateMessageAtIndex(idx, { content: streamingState.response }); + } + }); + } } clearUIState(): void { diff --git a/tools/server/webui/src/lib/types/index.ts b/tools/server/webui/src/lib/types/index.ts index cac6e50ce7..a09433b14e 100644 --- a/tools/server/webui/src/lib/types/index.ts +++ b/tools/server/webui/src/lib/types/index.ts @@ -92,5 +92,6 @@ export type { OpenAIToolDefinition, ServerStatus, ToolCallParams, - ToolExecutionResult + ToolExecutionResult, + Tool, } from './mcp'; diff --git a/tools/server/webui/src/lib/utils/debounce.ts b/tools/server/webui/src/lib/utils/debounce.ts new file mode 100644 index 0000000000..90a5a01783 --- /dev/null +++ b/tools/server/webui/src/lib/utils/debounce.ts @@ -0,0 +1,22 @@ +/** + * @param fn - The function to debounce + * @param delay - The delay in milliseconds + * @returns A debounced version of the function + */ +export function debounce) => void>( + fn: T, + delay: number +): (...args: Parameters) => void { + let timeoutId: ReturnType | null = null; + + return (...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + fn(...args); + timeoutId = null; + }, delay); + }; +}