feat: Architectural improvements
This commit is contained in:
parent
c02e83c32a
commit
6018f85c65
|
|
@ -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)');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -92,5 +92,6 @@ export type {
|
|||
OpenAIToolDefinition,
|
||||
ServerStatus,
|
||||
ToolCallParams,
|
||||
ToolExecutionResult
|
||||
ToolExecutionResult,
|
||||
Tool,
|
||||
} from './mcp';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue