refactor: Cleanup

This commit is contained in:
Aleksander Grygier 2026-02-09 13:12:10 +01:00
parent 10c1875cdb
commit 853f711896
17 changed files with 397 additions and 62 deletions

View File

@ -152,6 +152,19 @@
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
/**
*
*
* LIFECYCLE
*
*
*/
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
/**
*
*
@ -184,6 +197,14 @@
return true;
}
/**
*
*
* EVENT HANDLERS - File Management
*
*
*/
/**
*
*
@ -445,19 +466,6 @@
}
}
}
/**
*
*
* LIFECYCLE
*
*
*/
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
</script>
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />

View File

@ -78,6 +78,7 @@
extras?: DatabaseMessage['extra']
): ToolResultLine[] {
const lines = toolResult.split('\n');
return lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX);
if (!match || !extras) return { text: line };

View File

@ -131,6 +131,7 @@
{key}
</span>
</Tooltip.Trigger>
<Tooltip.Content>
<span class="max-w-xs break-all">{value}</span>
</Tooltip.Content>
@ -161,7 +162,9 @@
>
<div class="space-y-2">
<div class="h-3 w-3/4 animate-pulse rounded bg-foreground/20"></div>
<div class="h-3 w-full animate-pulse rounded bg-foreground/20"></div>
<div class="h-3 w-5/6 animate-pulse rounded bg-foreground/20"></div>
</div>
</div>

View File

@ -117,9 +117,11 @@
onclick={() => (activeView = ChatMessageStatsView.READING)}
>
<BookOpenText class="h-3 w-3" />
<span class="sr-only">Reading</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reading (prompt processing)</p>
</Tooltip.Content>
@ -139,9 +141,11 @@
disabled={isGenerationDisabled}
>
<Sparkles class="h-3 w-3" />
<span class="sr-only">Generation</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>
{isGenerationDisabled
@ -150,6 +154,7 @@
</p>
</Tooltip.Content>
</Tooltip.Root>
{#if hasAgenticStats}
<Tooltip.Root>
<Tooltip.Trigger>
@ -203,12 +208,14 @@
value="{predictedTokens?.toLocaleString()} tokens"
tooltipLabel="Generated tokens"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
value={formattedTime}
tooltipLabel="Generation time"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Gauge}
@ -222,12 +229,14 @@
value="{agenticTimings!.toolCallsCount} calls"
tooltipLabel="Tool calls executed"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
value={formattedAgenticToolsTime}
tooltipLabel="Tool execution time"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Gauge}
@ -241,12 +250,14 @@
value="{agenticTimings!.turns} turns"
tooltipLabel="Agentic turns (LLM calls)"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={WholeWord}
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
tooltipLabel="Total tokens generated"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
@ -260,12 +271,14 @@
value="{promptTokens} tokens"
tooltipLabel="Prompt tokens"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
value={formattedPromptTime ?? '0s'}
tooltipLabel="Prompt processing time"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Gauge}

View File

@ -117,6 +117,7 @@
<div class="mt-2 flex justify-end gap-2">
<Button class="h-8 px-3" onclick={editCtx.cancel} size="sm" variant="outline">
<X class="mr-1 h-3 w-3" />
Cancel
</Button>
@ -127,6 +128,7 @@
size="sm"
>
<Check class="mr-1 h-3 w-3" />
Save
</Button>
</div>
@ -174,6 +176,7 @@
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
></div>
<div
class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
>

View File

@ -28,13 +28,16 @@
);
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
},
delete: async (message: DatabaseMessage) => {
await chatStore.deleteMessage(message.id);
refreshAllMessages();
},
navigateToSibling: async (siblingId: string) => {
await conversationsStore.navigateToSibling(siblingId);
},
editWithBranching: async (
message: DatabaseMessage,
newContent: string,
@ -44,6 +47,7 @@
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
refreshAllMessages();
},
editWithReplacement: async (
message: DatabaseMessage,
newContent: string,
@ -53,6 +57,7 @@
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
refreshAllMessages();
},
editUserMessagePreserveResponses: async (
message: DatabaseMessage,
newContent: string,
@ -62,11 +67,13 @@
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
refreshAllMessages();
},
regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
onUserAction?.();
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
},
continueAssistantMessage: async (message: DatabaseMessage) => {
onUserAction?.();
await chatStore.continueAssistantMessage(message.id);

View File

@ -311,12 +311,6 @@
let activeSection = $state<SettingsSectionTitle>(
initialSection ?? SETTINGS_SECTION_TITLES.GENERAL
);
$effect(() => {
if (initialSection) {
activeSection = initialSection;
}
});
let currentSection = $derived(
settingSections.find((section) => section.title === activeSection) || settingSections[0]
);
@ -326,6 +320,12 @@
let canScrollRight = $state(false);
let scrollContainer: HTMLDivElement | undefined = $state();
$effect(() => {
if (initialSection) {
activeSection = initialSection;
}
});
function handleThemeChange(newTheme: string) {
localConfig.theme = newTheme;

View File

@ -51,6 +51,7 @@
const blob = new Blob([extra.content], { type: extra.mimeType || 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = extra.name || 'resource.txt';
document.body.appendChild(a);

View File

@ -47,6 +47,7 @@
<Dialog.Header>
<Dialog.Title>Model Information</Dialog.Title>
<Dialog.Description>Current model details and capabilities</Dialog.Description>
</Dialog.Header>
@ -108,6 +109,7 @@
{#if serverProps?.default_generation_settings?.n_ctx}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
<Table.Cell
>{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
>
@ -117,6 +119,7 @@
<Table.Cell class="h-10 align-middle font-medium text-red-500"
>Context Size</Table.Cell
>
<Table.Cell class="text-red-500">Not available</Table.Cell>
</Table.Row>
{/if}
@ -125,6 +128,7 @@
{#if modelMeta?.n_ctx_train}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
<Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
</Table.Row>
{/if}
@ -133,6 +137,7 @@
{#if modelMeta?.size}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
<Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
</Table.Row>
{/if}
@ -141,6 +146,7 @@
{#if modelMeta?.n_params}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
<Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
</Table.Row>
{/if}
@ -149,6 +155,7 @@
{#if modelMeta?.n_embd}
<Table.Row>
<Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
<Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
</Table.Row>
{/if}
@ -157,6 +164,7 @@
{#if modelMeta?.n_vocab}
<Table.Row>
<Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
<Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
</Table.Row>
{/if}
@ -172,6 +180,7 @@
<!-- Total Slots -->
<Table.Row>
<Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
<Table.Cell>{serverProps.total_slots}</Table.Cell>
</Table.Row>
@ -179,6 +188,7 @@
{#if modalities.length > 0}
<Table.Row>
<Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
<Table.Cell>
<div class="flex flex-wrap gap-1">
<BadgeModality {modalities} />
@ -190,6 +200,7 @@
<!-- Build Info -->
<Table.Row>
<Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
<Table.Cell class="align-middle font-mono text-xs"
>{serverProps.build_info}</Table.Cell
>
@ -199,6 +210,7 @@
{#if serverProps.chat_template}
<Table.Row>
<Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
<Table.Cell class="py-10">
<div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4">
<pre

View File

@ -11,6 +11,7 @@ export interface ResourceTreeNode {
export function parseResourcePath(uri: string): string[] {
try {
const withoutProtocol = uri.replace(/^[a-z]+:\/\//, '');
return withoutProtocol.split('/').filter((p) => p.length > 0);
} catch {
return [uri];
@ -19,6 +20,7 @@ export function parseResourcePath(uri: string): string[] {
export function getDisplayName(pathPart: string): string {
const withoutExt = pathPart.replace(/\.[^.]+$/, '');
return withoutExt
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
@ -58,6 +60,7 @@ export function buildResourceTree(
children: new Map()
});
}
return root;
}
@ -113,9 +116,11 @@ export function buildResourceTree(
export function countTreeResources(node: ResourceTreeNode): number {
if (node.resource) return 1;
let count = 0;
for (const child of node.children.values()) {
count += countTreeResources(child);
}
return count;
}
@ -126,6 +131,7 @@ export function getResourceIcon(resource: MCPResourceInfo) {
if (mimeType.startsWith('image/') || /\.(png|jpg|jpeg|gif|svg|webp)$/.test(uri)) {
return Image;
}
if (
mimeType.includes('json') ||
mimeType.includes('javascript') ||
@ -134,12 +140,15 @@ export function getResourceIcon(resource: MCPResourceInfo) {
) {
return Code;
}
if (mimeType.includes('text') || /\.(txt|md|log)$/.test(uri)) {
return FileText;
}
if (uri.includes('database') || uri.includes('db://')) {
return Database;
}
return File;
}
@ -147,8 +156,10 @@ export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode
return children.sort((a, b) => {
const aIsFolder = !a.resource && a.children.size > 0;
const bIsFolder = !b.resource && b.children.size > 0;
if (aIsFolder && !bIsFolder) return -1;
if (!aIsFolder && bIsFolder) return 1;
return a.name.localeCompare(b.name);
});
}

View File

@ -1,4 +1,25 @@
import { MimeTypeImage } from '$lib/enums';
// File extension patterns for resource type detection
export const IMAGE_FILE_EXTENSION_REGEX = /\.(png|jpg|jpeg|gif|svg|webp)$/;
export const CODE_FILE_EXTENSION_REGEX = /\.(js|ts|json|yaml|yml|xml|html|css)$/;
export const TEXT_FILE_EXTENSION_REGEX = /\.(txt|md|log)$/;
export const IMAGE_FILE_EXTENSION_REGEX = /\.(png|jpg|jpeg|gif|svg|webp)$/i;
export const CODE_FILE_EXTENSION_REGEX =
/\.(js|ts|json|yaml|yml|xml|html|css|py|rs|go|java|cpp|c|h|rb|sh|toml)$/i;
export const TEXT_FILE_EXTENSION_REGEX = /\.(txt|md|log)$/i;
// URI protocol prefix pattern
export const PROTOCOL_PREFIX_REGEX = /^[a-z]+:\/\//;
// File extension regex for display name extraction
export const FILE_EXTENSION_REGEX = /\.[^.]+$/;
/**
* Mapping from image MIME types to file extensions.
* Used for generating attachment filenames from MIME types.
*/
export const IMAGE_MIME_TO_EXTENSION: Record<string, string> = {
[MimeTypeImage.JPEG]: 'jpg',
'image/jpg': 'jpg',
[MimeTypeImage.PNG]: 'png',
[MimeTypeImage.GIF]: 'gif',
[MimeTypeImage.WEBP]: 'webp'
} as const;

View File

@ -146,6 +146,7 @@ export class ChatService {
.map((msg) => {
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
return ChatService.convertDbMessageToApiChatMessageData(dbMsg);
} else {
return msg as ApiChatMessageData;
@ -171,8 +172,10 @@ export class ChatService {
console.info(
`[ChatService] Skipping image attachment in message history (model "${options.model}" does not support vision)`
);
return false;
}
return true;
});
// If only text remains and it's a single part, simplify to string
@ -260,9 +263,11 @@ export class ChatService {
if (!response.ok) {
const error = await ChatService.parseErrorResponse(response);
if (onError) {
onError(error);
}
throw error;
}
@ -279,6 +284,7 @@ export class ChatService {
conversationId,
signal
);
return;
} else {
return ChatService.handleNonStreamResponse(
@ -317,9 +323,11 @@ export class ChatService {
}
console.error('Error in sendMessage:', error);
if (onError) {
onError(userFriendlyError);
}
throw userFriendlyError;
}
}
@ -438,6 +446,7 @@ export class ChatService {
const data = line.slice(6);
if (data === '[DONE]') {
streamFinished = true;
continue;
}
@ -543,6 +552,7 @@ export class ChatService {
if (!responseText.trim()) {
const noResponseError = new Error('No response received from server. Please try again.');
throw noResponseError;
}
@ -572,6 +582,7 @@ export class ChatService {
if (!content.trim() && !serializedToolCalls) {
const noResponseError = new Error('No response received from server. Please try again.');
throw noResponseError;
}
@ -691,9 +702,11 @@ export class ChatService {
role: message.role as MessageRole,
content: message.content
};
if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls;
}
return result;
}
@ -865,6 +878,7 @@ export class ChatService {
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
};
fallback.name = 'HttpError';
return fallback;
}
}
@ -896,18 +910,26 @@ export class ChatService {
// 1) root (some implementations provide `model` at the top level)
const rootModel = getTrimmedString(root.model);
if (rootModel) return rootModel;
if (rootModel) {
return rootModel;
}
// 2) streaming choice (delta) or final response (message)
const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
if (!firstChoice) return undefined;
if (!firstChoice) {
return undefined;
}
// priority: delta.model (first chunk) else message.model (final response)
const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
if (deltaModel) return deltaModel;
if (deltaModel) {
return deltaModel;
}
const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
if (messageModel) return messageModel;
if (messageModel) {
return messageModel;
}
// avoid guessing from non-standard locations (metadata, etc.)
return undefined;

View File

@ -159,6 +159,7 @@ export class MCPService {
} catch (sseError) {
const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
const sseMsg = sseError instanceof Error ? sseError.message : String(sseError);
throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`);
}
}
@ -168,7 +169,10 @@ export class MCPService {
* Extract server info from SDK Implementation type
*/
private static extractServerInfo(impl: Implementation | undefined): MCPServerInfo | undefined {
if (!impl) return undefined;
if (!impl) {
return undefined;
}
return {
name: impl.name,
version: impl.version,
@ -213,6 +217,7 @@ export class MCPService {
if (import.meta.env.DEV) {
console.log(`[MCPService][${serverName}] Creating transport...`);
}
const { transport, type: transportType } = this.createTransport(serverConfig);
// Setup WebSocket reconnection handler
@ -334,6 +339,7 @@ export class MCPService {
if (connection.transport.onclose) {
connection.transport.onclose = undefined;
}
await connection.client.close();
} catch (error) {
console.warn(`[MCPService][${connection.serverName}] Error during disconnect:`, error);
@ -346,6 +352,7 @@ export class MCPService {
static async listTools(connection: MCPConnection): Promise<Tool[]> {
try {
const result = await connection.client.listTools();
return result.tools ?? [];
} catch (error) {
console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error);
@ -360,6 +367,7 @@ export class MCPService {
static async listPrompts(connection: MCPConnection): Promise<Prompt[]> {
try {
const result = await connection.client.listPrompts();
return result.prompts ?? [];
} catch (error) {
console.warn(`[MCPService][${connection.serverName}] Failed to list prompts:`, error);

View File

@ -119,13 +119,16 @@ class AgenticStore {
}
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()) {
@ -137,18 +140,23 @@ class AgenticStore {
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 });
}
@ -412,6 +420,7 @@ class AgenticStore {
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
@ -422,6 +431,7 @@ class AgenticStore {
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
throw normalizedError;
}
@ -432,6 +442,7 @@ class AgenticStore {
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
@ -453,6 +464,7 @@ class AgenticStore {
function: call.function ? { ...call.function } : undefined
});
}
this.updateSession(conversationId, { totalToolCalls: allToolCalls.length });
onToolCallChunk?.(JSON.stringify(allToolCalls));
@ -470,6 +482,7 @@ class AgenticStore {
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
@ -493,6 +506,7 @@ class AgenticStore {
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
@ -519,6 +533,7 @@ class AgenticStore {
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return;
}
@ -588,10 +603,14 @@ class AgenticStore {
maxLines: number,
emit?: (chunk: string) => void
): void {
if (!emit) return;
if (!emit) {
return;
}
let output = `\n${AGENTIC_TAGS.TOOL_ARGS_END}`;
const lines = result.split('\n');
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
output += `\n${trimmedLines.join('\n')}\n${AGENTIC_TAGS.TOOL_CALL_END}\n`;
emit(output);
}
@ -600,26 +619,38 @@ class AgenticStore {
cleanedResult: string;
attachments: DatabaseMessageExtra[];
} {
if (!result.trim()) return { cleanedResult: result, attachments: [] };
if (!result.trim()) {
return { cleanedResult: result, attachments: [] };
}
const lines = result.split('\n');
const attachments: DatabaseMessageExtra[] = [];
let attachmentIndex = 0;
const cleanedLines = lines.map((line) => {
const trimmedLine = line.trim();
const match = trimmedLine.match(/^data:([^;]+);base64,([A-Za-z0-9+/]+=*)$/);
if (!match) return line;
if (!match) {
return line;
}
const mimeType = match[1].toLowerCase();
const base64Data = match[2];
if (!base64Data) return line;
if (!base64Data) {
return line;
}
attachmentIndex += 1;
const name = this.buildAttachmentName(mimeType, attachmentIndex);
if (mimeType.startsWith('image/')) {
attachments.push({ type: AttachmentType.IMAGE, name, base64Url: trimmedLine });
return `[Attachment saved: ${name}]`;
}
return line;
});
@ -644,18 +675,23 @@ 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;
}

View File

@ -63,6 +63,7 @@ class MCPResourceStore {
for (const serverRes of this._serverResources.values()) {
count += serverRes.resources.length;
}
return count;
}
@ -71,6 +72,7 @@ class MCPResourceStore {
for (const serverRes of this._serverResources.values()) {
count += serverRes.templates.length;
}
return count;
}
@ -134,6 +136,7 @@ class MCPResourceStore {
*/
setServerError(serverName: string, error: string): void {
const existing = this._serverResources.get(serverName);
if (existing) {
this._serverResources.set(serverName, { ...existing, loading: false, error });
} else {
@ -159,6 +162,7 @@ class MCPResourceStore {
*/
getAllResourceInfos(): MCPResourceInfo[] {
const result: MCPResourceInfo[] = [];
for (const [serverName, serverRes] of this._serverResources) {
for (const resource of serverRes.resources) {
result.push({
@ -173,6 +177,7 @@ class MCPResourceStore {
});
}
}
return result;
}
@ -181,6 +186,7 @@ class MCPResourceStore {
*/
getAllTemplateInfos(): MCPResourceTemplateInfo[] {
const result: MCPResourceTemplateInfo[] = [];
for (const [serverName, serverRes] of this._serverResources) {
for (const template of serverRes.templates) {
result.push({
@ -195,6 +201,7 @@ class MCPResourceStore {
});
}
}
return result;
}
@ -203,18 +210,21 @@ class MCPResourceStore {
*/
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`);
}
@ -260,6 +270,7 @@ class MCPResourceStore {
if (age > CACHE_TTL_MS && !cached.subscribed) {
// Cache expired and not subscribed, remove it
this._cachedResources.delete(uri);
return undefined;
}
@ -468,6 +479,7 @@ class MCPResourceStore {
};
}
}
return undefined;
}
@ -480,6 +492,7 @@ class MCPResourceStore {
return serverName;
}
}
return undefined;
}

View File

@ -56,16 +56,25 @@ import type { McpServerOverride } from '$lib/types/database';
import type { SettingsConfigType } from '$lib/types/settings';
function generateMcpServerId(id: unknown, index: number): string {
if (typeof id === 'string' && id.trim()) return id.trim();
if (typeof id === 'string' && id.trim()) {
return id.trim();
}
return `${MCP_SERVER_ID_PREFIX}${index + 1}`;
}
function parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
if (!rawServers) return [];
if (!rawServers) {
return [];
}
let parsed: unknown;
if (typeof rawServers === 'string') {
const trimmed = rawServers.trim();
if (!trimmed) return [];
if (!trimmed) {
return [];
}
try {
parsed = JSON.parse(trimmed);
} catch (error) {
@ -75,7 +84,10 @@ function parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
} else {
parsed = rawServers;
}
if (!Array.isArray(parsed)) return [];
if (!Array.isArray(parsed)) {
return [];
}
return parsed.map((entry, index) => {
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
@ -95,7 +107,10 @@ function buildServerConfig(
entry: MCPServerSettingsEntry,
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
): MCPServerConfig | undefined {
if (!entry?.url) return undefined;
if (!entry?.url) {
return undefined;
}
let headers: Record<string, string> | undefined;
if (entry.headers) {
try {
@ -120,7 +135,10 @@ function checkServerEnabled(
server: MCPServerSettingsEntry,
perChatOverrides?: McpServerOverride[]
): boolean {
if (!server.enabled) return false;
if (!server.enabled) {
return false;
}
if (perChatOverrides) {
const override = perChatOverrides.find((o) => o.serverId === server.id);
return override?.enabled ?? false;
@ -133,14 +151,20 @@ function buildMcpClientConfigInternal(
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseServerSettings(cfg.mcpServers);
if (!rawServers.length) return undefined;
if (!rawServers.length) {
return undefined;
}
const servers: Record<string, MCPServerConfig> = {};
for (const [index, entry] of rawServers.entries()) {
if (!checkServerEnabled(entry, perChatOverrides)) continue;
const normalized = buildServerConfig(entry);
if (normalized) servers[generateMcpServerId(entry.id, index)] = normalized;
}
if (Object.keys(servers).length === 0) return undefined;
if (Object.keys(servers).length === 0) {
return undefined;
}
return {
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
capabilities: DEFAULT_MCP_CONFIG.capabilities,
@ -303,13 +327,17 @@ class MCPStore {
*/
getServerFavicon(serverId: string): string | null {
const server = this.getServerById(serverId);
if (!server) return null;
if (!server) {
return null;
}
return getFaviconUrl(server.url);
}
isAnyServerLoading(): boolean {
return this.getServers().some((s) => {
const state = this.getHealthCheckState(s.id);
return (
state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING
);
@ -318,7 +346,10 @@ class MCPStore {
getServersSorted(): MCPServerSettingsEntry[] {
const servers = this.getServers();
if (this.isAnyServerLoading()) return servers;
if (this.isAnyServerLoading()) {
return servers;
}
return [...servers].sort((a, b) =>
this.getServerLabel(a).localeCompare(this.getServerLabel(b))
);
@ -366,24 +397,41 @@ class MCPStore {
getEnabledServersForConversation(
perChatOverrides?: McpServerOverride[]
): MCPServerSettingsEntry[] {
if (!perChatOverrides?.length) return [];
if (!perChatOverrides?.length) {
return [];
}
return this.getServers().filter((server) => {
if (!server.enabled) return false;
if (!server.enabled) {
return false;
}
const override = perChatOverrides.find((o) => o.serverId === server.id);
return override?.enabled ?? false;
});
}
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
if (!browser) return false;
if (!browser) {
return false;
}
const mcpConfig = buildMcpClientConfigInternal(config(), perChatOverrides);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
if (!signature) {
await this.shutdown();
return false;
}
if (this.isInitialized && this.configSignature === signature) return true;
if (this.initPromise && this.configSignature === signature) return this.initPromise;
if (this.isInitialized && this.configSignature === signature) {
return true;
}
if (this.initPromise && this.configSignature === signature) {
return this.initPromise;
}
if (this.connections.size > 0 || this.initPromise) await this.shutdown();
return this.initialize(signature, mcpConfig!);
}
@ -391,12 +439,16 @@ class MCPStore {
private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise<boolean> {
this.updateState({ isInitializing: true, error: null });
this.configSignature = signature;
const serverEntries = Object.entries(mcpConfig.servers);
if (serverEntries.length === 0) {
this.updateState({ isInitializing: false, toolCount: 0, connectedServers: [] });
return false;
}
this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries);
return this.initPromise;
}
@ -427,6 +479,7 @@ class MCPStore {
},
listChangedHandlers
);
return { name, connection };
})
);
@ -435,12 +488,15 @@ class MCPStore {
if (result.status === 'fulfilled')
await MCPService.disconnect(result.value.connection).catch(console.warn);
}
return false;
}
for (const result of results) {
if (result.status === 'fulfilled') {
const { name, connection } = result.value;
this.connections.set(name, connection);
for (const tool of connection.tools) {
if (this.toolsIndex.has(tool.name))
console.warn(
@ -452,6 +508,7 @@ class MCPStore {
console.error(`[MCPStore] Failed to connect:`, result.reason);
}
}
const successCount = this.connections.size;
if (successCount === 0 && serverEntries.length > 0) {
this.updateState({
@ -461,8 +518,10 @@ class MCPStore {
connectedServers: []
});
this.initPromise = null;
return false;
}
this.updateState({
isInitializing: false,
error: null,
@ -470,6 +529,7 @@ class MCPStore {
connectedServers: Array.from(this.connections.keys())
});
this.initPromise = null;
return true;
}
@ -497,11 +557,16 @@ class MCPStore {
private handleToolsListChanged(serverName: string, tools: Tool[]): void {
const connection = this.connections.get(serverName);
if (!connection) return;
if (!connection) {
return;
}
for (const [toolName, ownerServer] of this.toolsIndex.entries()) {
if (ownerServer === serverName) this.toolsIndex.delete(toolName);
}
connection.tools = tools;
for (const tool of tools) {
if (this.toolsIndex.has(tool.name))
console.warn(
@ -537,7 +602,11 @@ class MCPStore {
await this.initPromise.catch(() => {});
this.initPromise = null;
}
if (this.connections.size === 0) return;
if (this.connections.size === 0) {
return;
}
await Promise.all(
Array.from(this.connections.values()).map((conn) =>
MCPService.disconnect(conn).catch((error) =>
@ -545,6 +614,7 @@ class MCPStore {
)
)
);
this.connections.clear();
this.toolsIndex.clear();
this.serverConfigs.clear();
@ -560,12 +630,14 @@ class MCPStore {
// Guard against concurrent reconnections
if (this.reconnectingServers.has(serverName)) {
console.log(`[MCPStore][${serverName}] Reconnection already in progress, skipping`);
return;
}
const serverConfig = this.serverConfigs.get(serverName);
if (!serverConfig) {
console.error(`[MCPStore] No config found for ${serverName}, cannot reconnect`);
return;
}
@ -616,6 +688,7 @@ class MCPStore {
getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [];
for (const connection of this.connections.values()) {
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
@ -623,6 +696,7 @@ class MCPStore {
properties: {},
required: []
};
tools.push({
type: 'function' as const,
function: {
@ -633,11 +707,15 @@ class MCPStore {
});
}
}
return tools;
}
private normalizeSchemaProperties(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema;
if (!schema || typeof schema !== 'object') {
return schema;
}
const normalized = { ...schema };
if (normalized.properties && typeof normalized.properties === 'object') {
const props = normalized.properties as Record<string, Record<string, unknown>>;
@ -671,23 +749,29 @@ class MCPStore {
}
normalized.properties = normalizedProps;
}
return normalized;
}
getToolNames(): string[] {
return Array.from(this.toolsIndex.keys());
}
hasTool(toolName: string): boolean {
return this.toolsIndex.has(toolName);
}
getToolServer(toolName: string): string | undefined {
return this.toolsIndex.get(toolName);
}
hasPromptsSupport(): boolean {
for (const connection of this.connections.values()) {
if (connection.serverCapabilities?.prompts) return true;
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
@ -705,8 +789,11 @@ class MCPStore {
const enabledServerIds = new Set(
perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)
);
// No enabled servers = no capability
if (enabledServerIds.size === 0) return false;
if (enabledServerIds.size === 0) {
return false;
}
// Check health check states for enabled servers with prompts capability
for (const [serverId, state] of Object.entries(this._healthChecks)) {
@ -718,6 +805,7 @@ class MCPStore {
return true;
}
}
// Also check active connections as fallback
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
@ -725,6 +813,7 @@ class MCPStore {
return true;
}
}
return false;
}
@ -737,19 +826,24 @@ class MCPStore {
return true;
}
}
for (const connection of this.connections.values()) {
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
async getAllPrompts(): Promise<MCPPromptInfo[]> {
const results: MCPPromptInfo[] = [];
for (const [serverName, connection] of this.connections) {
if (!connection.serverCapabilities?.prompts) continue;
const prompts = await MCPService.listPrompts(connection);
for (const prompt of prompts) {
results.push({
name: prompt.name,
@ -764,6 +858,7 @@ class MCPStore {
});
}
}
return results;
}
@ -774,16 +869,21 @@ class MCPStore {
): Promise<GetPromptResult> {
const connection = this.connections.get(serverName);
if (!connection) throw new Error(`Server "${serverName}" not found for prompt "${promptName}"`);
return MCPService.getPrompt(connection, promptName, args);
}
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
const toolName = toolCall.function.name;
const serverName = this.toolsIndex.get(toolName);
if (!serverName) throw new Error(`Unknown tool: ${toolName}`);
const connection = this.connections.get(serverName);
if (!connection) throw new Error(`Server "${serverName}" is not connected`);
const args = this.parseToolArguments(toolCall.function.arguments);
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
}
@ -796,25 +896,34 @@ class MCPStore {
if (!serverName) throw new Error(`Unknown tool: ${toolName}`);
const connection = this.connections.get(serverName);
if (!connection) throw new Error(`Server "${serverName}" is not connected`);
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
}
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
if (typeof args === 'string') {
const trimmed = args.trim();
if (trimmed === '') return {};
if (trimmed === '') {
return {};
}
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
throw new Error(
`Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`
);
return parsed as Record<string, unknown>;
} catch (error) {
throw new Error(`Failed to parse tool arguments as JSON: ${(error as Error).message}`);
}
}
if (typeof args === 'object' && args !== null && !Array.isArray(args)) return args;
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
return args;
}
throw new Error(`Invalid tool arguments type: ${typeof args}`);
}
@ -829,7 +938,10 @@ class MCPStore {
console.warn(`[MCPStore] Server "${serverName}" is not connected`);
return null;
}
if (!connection.serverCapabilities?.completions) return null;
if (!connection.serverCapabilities?.completions) {
return null;
}
return MCPService.complete(
connection,
{ type: MCPRefType.PROMPT, name: promptName },
@ -838,7 +950,10 @@ class MCPStore {
}
private parseHeaders(headersJson?: string): Record<string, string> | undefined {
if (!headersJson?.trim()) return undefined;
if (!headersJson?.trim()) {
return undefined;
}
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
@ -846,6 +961,7 @@ class MCPStore {
} catch {
console.warn('[MCPStore] Failed to parse custom headers JSON:', headersJson);
}
return undefined;
}
@ -863,7 +979,11 @@ class MCPStore {
const serversToCheck = skipIfChecked
? servers.filter((s) => !this.hasHealthCheck(s.id) && s.url.trim())
: servers.filter((s) => s.url.trim());
if (serversToCheck.length === 0) return;
if (serversToCheck.length === 0) {
return;
}
const BATCH_SIZE = 5;
for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) {
const batch = serversToCheck.slice(i, i + BATCH_SIZE);
@ -921,9 +1041,11 @@ class MCPStore {
this.connections.delete(server.id);
}
}
const trimmedUrl = server.url.trim();
const logs: MCPConnectionLog[] = [];
let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE;
if (!trimmedUrl) {
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.ERROR,
@ -932,11 +1054,13 @@ class MCPStore {
});
return;
}
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.CONNECTING,
phase: MCPConnectionPhase.TRANSPORT_CREATING,
logs: []
});
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
const headers = this.parseHeaders(server.headers);
@ -976,15 +1100,18 @@ class MCPStore {
}
}
);
const tools = connection.tools.map((tool) => ({
name: tool.name,
description: tool.description,
title: tool.title
}));
const capabilities = buildCapabilitiesInfo(
connection.serverCapabilities,
connection.clientCapabilities
);
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.SUCCESS,
tools,
@ -1005,12 +1132,14 @@ class MCPStore {
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred';
logs.push({
timestamp: new Date(),
phase: MCPConnectionPhase.ERROR,
message: `Connection failed: ${message}`,
level: MCPLogLevel.ERROR
});
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.ERROR,
message,
@ -1047,6 +1176,7 @@ class MCPStore {
getServersStatus(): ServerStatus[] {
const statuses: ServerStatus[] = [];
for (const [name, connection] of this.connections) {
statuses.push({
name,
@ -1055,6 +1185,7 @@ class MCPStore {
error: undefined
});
}
return statuses;
}
@ -1068,6 +1199,7 @@ class MCPStore {
instructions: string;
}> {
const results: Array<{ serverName: string; serverTitle?: string; instructions: string }> = [];
for (const [serverName, connection] of this.connections) {
if (connection.instructions) {
results.push({
@ -1077,6 +1209,7 @@ class MCPStore {
});
}
}
return results;
}
@ -1090,6 +1223,7 @@ class MCPStore {
instructions: string;
}> {
const results: Array<{ serverId: string; serverTitle?: string; instructions: string }> = [];
for (const [serverId, state] of Object.entries(this._healthChecks)) {
if (state.status === HealthCheckStatus.SUCCESS && state.instructions) {
results.push({
@ -1099,6 +1233,7 @@ class MCPStore {
});
}
}
return results;
}
@ -1107,8 +1242,11 @@ class MCPStore {
*/
hasServerInstructions(): boolean {
for (const connection of this.connections.values()) {
if (connection.instructions) return true;
if (connection.instructions) {
return true;
}
}
return false;
}
@ -1135,7 +1273,9 @@ class MCPStore {
perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)
);
// No enabled servers = no capability
if (enabledServerIds.size === 0) return false;
if (enabledServerIds.size === 0) {
return false;
}
// Check health check states for enabled servers with resources capability
for (const [serverId, state] of Object.entries(this._healthChecks)) {
@ -1147,6 +1287,7 @@ class MCPStore {
return true;
}
}
// Also check active connections as fallback
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
@ -1154,6 +1295,7 @@ class MCPStore {
return true;
}
}
return false;
}
@ -1166,11 +1308,13 @@ class MCPStore {
return true;
}
}
for (const connection of this.connections.values()) {
if (MCPService.supportsResources(connection)) {
return true;
}
}
return false;
}
@ -1217,14 +1361,19 @@ class MCPStore {
if (!forceRefresh) {
const allServersCached = serversWithResources.every((serverName) => {
const serverRes = mcpResourceStore.getServerResources(serverName);
if (!serverRes || !serverRes.lastFetched) return false;
if (!serverRes || !serverRes.lastFetched) {
return false;
}
// Cache is valid for 5 minutes
const age = Date.now() - serverRes.lastFetched.getTime();
return age < 5 * 60 * 1000;
});
if (allServersCached) {
console.log('[MCPStore] Using cached resources');
return;
}
}
@ -1286,12 +1435,14 @@ class MCPStore {
const serverName = mcpResourceStore.findServerForUri(uri);
if (!serverName) {
console.error(`[MCPStore] No server found for resource URI: ${uri}`);
return null;
}
const connection = this.connections.get(serverName);
if (!connection) {
console.error(`[MCPStore] No connection found for server: ${serverName}`);
return null;
}
@ -1306,6 +1457,7 @@ class MCPStore {
return result.contents;
} catch (error) {
console.error(`[MCPStore] Failed to read resource ${uri}:`, error);
return null;
}
}
@ -1317,12 +1469,14 @@ class MCPStore {
const serverName = mcpResourceStore.findServerForUri(uri);
if (!serverName) {
console.error(`[MCPStore] No server found for resource URI: ${uri}`);
return false;
}
const connection = this.connections.get(serverName);
if (!connection) {
console.error(`[MCPStore] No connection found for server: ${serverName}`);
return false;
}
@ -1333,9 +1487,11 @@ class MCPStore {
try {
await MCPService.subscribeResource(connection, uri);
mcpResourceStore.addSubscription(uri, serverName);
return true;
} catch (error) {
console.error(`[MCPStore] Failed to subscribe to resource ${uri}:`, error);
return false;
}
}
@ -1347,21 +1503,25 @@ class MCPStore {
const serverName = mcpResourceStore.findServerForUri(uri);
if (!serverName) {
console.error(`[MCPStore] No server found for resource URI: ${uri}`);
return false;
}
const connection = this.connections.get(serverName);
if (!connection) {
console.error(`[MCPStore] No connection found for server: ${serverName}`);
return false;
}
try {
await MCPService.unsubscribeResource(connection, uri);
mcpResourceStore.removeSubscription(uri);
return true;
} catch (error) {
console.error(`[MCPStore] Failed to unsubscribe from resource ${uri}:`, error);
return false;
}
}
@ -1374,6 +1534,7 @@ class MCPStore {
const resourceInfo = mcpResourceStore.findResourceByUri(uri);
if (!resourceInfo) {
console.error(`[MCPStore] Resource not found: ${uri}`);
return null;
}
@ -1388,6 +1549,7 @@ class MCPStore {
// Fetch content
try {
const content = await this.readResource(uri);
if (content) {
mcpResourceStore.updateAttachmentContent(attachment.id, content);
} else {

View File

@ -1,7 +1,21 @@
import type { MCPServerSettingsEntry } from '$lib/types';
import { MCPTransportType, MCPLogLevel, UrlPrefix, MimeTypePrefix } from '$lib/enums';
import type { MCPServerSettingsEntry, MCPResourceContent, MCPResourceInfo } from '$lib/types';
import {
MCPTransportType,
MCPLogLevel,
UrlPrefix,
MimeTypePrefix,
MimeTypeIncludes,
UriPattern
} from '$lib/enums';
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
import { Info, AlertTriangle, XCircle } from '@lucide/svelte';
import {
IMAGE_FILE_EXTENSION_REGEX,
CODE_FILE_EXTENSION_REGEX,
TEXT_FILE_EXTENSION_REGEX,
PROTOCOL_PREFIX_REGEX,
FILE_EXTENSION_REGEX
} from '$lib/constants/mcp-resource';
import { Database, File, FileText, Image, Code, Info, AlertTriangle, XCircle } from '@lucide/svelte';
import type { Component } from 'svelte';
/**