feat: Architectural improvements

This commit is contained in:
Aleksander Grygier 2026-01-22 18:11:53 +01:00
parent c02e83c32a
commit 6018f85c65
12 changed files with 399 additions and 85 deletions

View File

@ -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)');
}
}

View File

@ -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(),

View File

@ -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<string, string>();
private configSignature: string | null = null;
private initPromise: Promise<boolean> | 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<void> {
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<void> {
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;
}

View File

@ -292,6 +292,7 @@
iconClass={streamingIconClass}
title={section.toolName || 'Tool call'}
subtitle={streamingSubtitle}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
@ -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)}
>
<div class="pt-3">

View File

@ -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

View File

@ -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();
}
</script>
<Collapsible.Root
@ -77,7 +100,9 @@
<Collapsible.Content>
<div
bind:this={contentContainer}
class="overflow-y-auto border-t border-muted px-3 pb-3"
onscroll={handleScroll}
style="max-height: calc(100dvh - var(--chat-form-area-height));"
>
{@render children()}

View File

@ -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<typeof setInterval> | undefined;
let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
let showFileErrorDialog = $state(false);
let uploadedFiles = $state<ChatUploadedFile[]>([]);
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<boolean> {
@ -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);
});
</script>
@ -388,11 +339,8 @@
class="mb-16 md:mb-24"
messages={activeMessages()}
onUserAction={() => {
if (!disableAutoScroll) {
userScrolledUp = false;
autoScrollEnabled = true;
scrollChatToBottom();
}
autoScroll.enable();
autoScroll.scrollToBottom();
}}
/>

View File

@ -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<typeof setInterval> | undefined;
private _scrollTimeout: ReturnType<typeof setTimeout> | 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);
}

View File

@ -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<MCPConnection> {
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);
}
}

View File

@ -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 {

View File

@ -92,5 +92,6 @@ export type {
OpenAIToolDefinition,
ServerStatus,
ToolCallParams,
ToolExecutionResult
ToolExecutionResult,
Tool,
} from './mcp';

View File

@ -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<T extends (...args: Parameters<T>) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
};
}