refactor: Cleanup

This commit is contained in:
Aleksander Grygier 2026-01-24 21:29:32 +01:00
parent fc4c392dce
commit d938994395
28 changed files with 323 additions and 310 deletions

Binary file not shown.

View File

@ -26,8 +26,7 @@
import { mcpClient } from '$lib/clients';
import { ChatService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { getAgenticConfig } from '$lib/utils/agentic';
import { toAgenticMessages } from '$lib/utils';
import { getAgenticConfig, toAgenticMessages } from '$lib/utils';
import type { AgenticMessage, AgenticToolCallList } from '$lib/types/agentic';
import type {
ApiChatCompletionToolCall,

View File

@ -12,10 +12,10 @@ import {
normalizeModelName,
filterByLeafNodeId,
findDescendantMessages,
findLeafNode
findLeafNode,
getAgenticConfig
} from '$lib/utils';
import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
import { getAgenticConfig } from '$lib/utils/agentic';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
import { REASONING_TAGS } from '$lib/constants/agentic';
import type { ChatMessageTimings, ChatMessagePromptProgress } from '$lib/types/chat';

View File

@ -25,8 +25,8 @@ import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database.service';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
import { getEnabledServersForConversation } from '$lib/utils/mcp';
import { mcpClient } from '$lib/clients/mcp.client';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { McpServerOverride } from '$lib/types/database';
import { MessageRole } from '$lib/enums';
@ -182,7 +182,7 @@ export class ConversationsClient {
return;
}
const enabledServers = getEnabledServersForConversation(config(), mcpServerOverrides);
const enabledServers = mcpStore.getEnabledServersForConversation(mcpServerOverrides);
if (enabledServers.length === 0) {
return;

View File

@ -49,9 +49,158 @@ 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';
import { buildMcpClientConfig, detectMcpTransportFromUrl } from '$lib/utils/mcp';
import { detectMcpTransportFromUrl } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
import type { MCPServerConfig, MCPServerSettingsEntry } from '$lib/types';
import type { SettingsConfigType } from '$lib/types/settings';
/**
* Generates a valid MCP server ID from user input.
* Returns the trimmed ID if valid, otherwise generates 'server-{index+1}'.
*/
function generateMcpServerId(id: unknown, index: number): string {
if (typeof id === 'string' && id.trim()) {
return id.trim();
}
return `${MCP_SERVER_ID_PREFIX}${index + 1}`;
}
/**
* Parses MCP server settings from a JSON string or array.
* requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
* @param rawServers - The raw servers to parse
* @returns An empty array if the input is invalid.
*/
function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
if (!rawServers) return [];
let parsed: unknown;
if (typeof rawServers === 'string') {
const trimmed = rawServers.trim();
if (!trimmed) return [];
try {
parsed = JSON.parse(trimmed);
} catch (error) {
console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
return [];
}
} else {
parsed = rawServers;
}
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;
return {
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
url,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
headers: headers || undefined
} satisfies MCPServerSettingsEntry;
});
}
/**
* Builds an MCP server configuration from a server settings entry.
* @param entry - The server settings entry to build the configuration from
* @param connectionTimeoutMs - The connection timeout in milliseconds
* @returns The built server configuration, or undefined if the entry is invalid
*/
function buildServerConfig(
entry: MCPServerSettingsEntry,
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
): MCPServerConfig | undefined {
if (!entry?.url) {
return undefined;
}
let headers: Record<string, string> | undefined;
if (entry.headers) {
try {
const parsed = JSON.parse(entry.headers);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
headers = parsed as Record<string, string>;
}
} catch {
console.warn('[MCP] Failed to parse custom headers JSON, ignoring:', entry.headers);
}
}
return {
url: entry.url,
transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
headers
};
}
/**
* Checks if a server is enabled for the current chat.
* Server must be available (server.enabled) AND have a per-chat override enabling it.
* Pure helper function - no side effects.
*/
function checkServerEnabled(
server: MCPServerSettingsEntry,
perChatOverrides?: McpServerOverride[]
): boolean {
if (!server.enabled) {
return false;
}
if (perChatOverrides) {
const override = perChatOverrides.find((o) => o.serverId === server.id);
return override?.enabled ?? false;
}
return false;
}
/**
* Builds MCP client configuration from settings.
* Returns undefined if no valid servers are configured.
* @param config - Global settings configuration
* @param perChatOverrides - Optional per-chat server overrides
*/
export function buildMcpClientConfig(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseMcpServerSettings(config.mcpServers);
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;
}
return {
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
capabilities: DEFAULT_MCP_CONFIG.capabilities,
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
servers
};
}
/**
* Build capabilities info from server and client capabilities
@ -613,8 +762,6 @@ export class MCPClient {
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
}
mcpStore.incrementServerUsage(serverName);
const args = this.parseToolArguments(toolCall.function.arguments);
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);

View File

@ -4,7 +4,7 @@ import type {
ApiChatCompletionStreamChunk
} from '$lib/types/api';
import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat';
import { mergeToolCallDeltas, extractModelName } from '$lib/utils/chat-stream';
import { mergeToolCallDeltas, extractModelName } from '$lib/utils';
import type { AgenticChatCompletionRequest } from '$lib/types/agentic';
export type OpenAISseCallbacks = {

View File

@ -2,11 +2,10 @@
import { mcpClient } from '$lib/clients/mcp.client';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { getFaviconUrl, getMcpServerLabel } from '$lib/utils/mcp';
import { getFaviconUrl, debounce } from '$lib/utils';
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
import { fly } from 'svelte/transition';
import { SvelteMap } from 'svelte/reactivity';
import { debounce } from '$lib/utils/debounce';
import { SearchInput } from '$lib/components/app';
interface Props {
@ -65,11 +64,9 @@
function getServerLabel(serverId: string): string {
const server = serverSettingsMap.get(serverId);
if (!server) return serverId;
const healthState = mcpStore.getHealthCheckState(serverId);
return getMcpServerLabel(server, healthState);
return mcpStore.getServerLabel(server);
}
$effect(() => {

View File

@ -15,7 +15,7 @@
import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS } from '$lib/constants/agentic';
import { formatJsonPretty } from '$lib/utils/formatters';
import { formatJsonPretty } from '$lib/utils';
import type { DatabaseMessage } from '$lib/types/database';
interface Props {

View File

@ -4,6 +4,7 @@
import { Switch } from '$lib/components/ui/switch';
import { ChatFormInputArea, DialogConfirmation } from '$lib/components/app';
import { chatStore } from '$lib/stores/chat.svelte';
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
interface Props {
editedContent: string;
@ -104,7 +105,6 @@
async function handleFilesAdd(files: File[]) {
if (!onEditedUploadedFilesChange) return;
const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
const processed = await processFilesToChatUploaded(files);
onEditedUploadedFilesChange([...editedUploadedFiles, processed].flat());

View File

@ -4,7 +4,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums';
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
import { formatPerformanceTime } from '$lib/utils/formatters';
import { formatPerformanceTime } from '$lib/utils';
interface Props {
predictedTokens?: number;

View File

@ -9,7 +9,7 @@
import Input from '$lib/components/ui/input/input.svelte';
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { getPreviewText } from '$lib/utils/text';
import { getPreviewText } from '$lib/utils';
import ChatSidebarActions from './ChatSidebarActions.svelte';
const sidebar = Sidebar.useSidebar();

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import type { DatabaseMessageExtraMcpPrompt, MCPServerSettingsEntry } from '$lib/types';
import { getFaviconUrl, getMcpServerLabel } from '$lib/utils/mcp';
import { getFaviconUrl } from '$lib/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { SvelteMap } from 'svelte/reactivity';
@ -51,8 +51,7 @@
const server = serverSettingsMap.get(prompt.serverName);
if (!server) return prompt.serverName;
const healthState = mcpStore.getHealthCheckState(server.id);
return getMcpServerLabel(server, healthState);
return mcpStore.getServerLabel(server);
}
let contentParts = $derived.by((): ContentPart[] => {

View File

@ -2,7 +2,7 @@
import { cn } from '$lib/components/ui/utils';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { getFaviconUrl } from '$lib/utils/mcp';
import { getFaviconUrl } from '$lib/utils';
import { HealthCheckStatus } from '$lib/enums';
interface Props {

View File

@ -7,7 +7,7 @@
import { SearchableDropdownMenu } from '$lib/components/app';
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { getMcpServerLabel, getFaviconUrl } from '$lib/utils/mcp';
import { getFaviconUrl } from '$lib/utils';
import type { MCPServerSettingsEntry } from '$lib/types';
import { HealthCheckStatus } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
@ -56,7 +56,7 @@
);
function getServerLabel(server: MCPServerSettingsEntry): string {
return getMcpServerLabel(server, mcpStore.getHealthCheckState(server.id));
return mcpStore.getServerLabel(server);
}
function handleDropdownOpen(open: boolean) {

View File

@ -3,8 +3,7 @@
import * as Collapsible from '$lib/components/ui/collapsible';
import { cn } from '$lib/components/ui/utils';
import type { MCPConnectionLog } from '$lib/types';
import { formatTime } from '$lib/utils/formatters';
import { getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils/mcp';
import { formatTime, getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils';
interface Props {
logs: MCPConnectionLog[];

View File

@ -3,7 +3,6 @@
import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
import { getMcpServerLabel } from '$lib/utils/mcp';
import { HealthCheckStatus } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpClient } from '$lib/clients/mcp.client';
@ -27,7 +26,7 @@
let { server, faviconUrl, enabled, onToggle, onUpdate, onDelete }: Props = $props();
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
let displayName = $derived(getMcpServerLabel(server, healthState));
let displayName = $derived(mcpStore.getServerLabel(server));
let isIdle = $derived(healthState.status === HealthCheckStatus.Idle);
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.Connecting);
let isConnected = $derived(healthState.status === HealthCheckStatus.Success);

View File

@ -2,7 +2,7 @@
import { Input } from '$lib/components/ui/input';
import { KeyValuePairs } from '$lib/components/app';
import type { KeyValuePair } from '$lib/types';
import { parseHeadersToArray, serializeHeaders } from '$lib/utils/mcp';
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
interface Props {
url: string;

View File

@ -2,7 +2,7 @@
import { Plus, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { getFaviconUrl } from '$lib/utils/mcp';
import { getFaviconUrl } from '$lib/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { McpServerCard } from '$lib/components/app/mcp/McpServerCard';

View File

@ -16,14 +16,13 @@
import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
import { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images';
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
import '$styles/katex-custom.scss';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { mode } from 'mode-watcher';
import CodePreviewDialog from './CodePreviewDialog.svelte';
import type { DatabaseMessage } from '$lib/types/database';
import { getImageErrorFallbackHtml } from '$lib/utils/image-error-fallback';
interface Props {
message?: DatabaseMessage;

View File

@ -7,3 +7,5 @@ export const DEFAULT_MCP_CONFIG = {
requestTimeoutSeconds: 300, // 5 minutes for long-running tools
connectionTimeoutMs: 10_000 // 10 seconds for connection establishment
} as const;
export const MCP_SERVER_ID_PREFIX = 'LlamaCpp-WebUI-MCP-Server-';

View File

@ -28,6 +28,7 @@
import { browser } from '$app/environment';
import type { AgenticFlowParams, AgenticFlowResult } from '$lib/clients';
import type { AgenticSession } from '$lib/types/agentic';
import { agenticClient } from '$lib/clients/agentic.client';
export type {
AgenticFlowCallbacks,
@ -56,8 +57,8 @@ class AgenticStore {
*/
private _sessions = $state<Map<string, AgenticSession>>(new Map());
/** Reference to the client (lazy loaded to avoid circular dependency) */
private _client: typeof import('$lib/clients/agentic.client').agenticClient | null = null;
/** Reference to the client */
private _client = agenticClient;
private get client() {
return this._client;
@ -65,19 +66,18 @@ class AgenticStore {
/** Check if store is ready (client initialized) */
get isReady(): boolean {
return this._client !== null;
return this._initialized;
}
private _initialized = false;
/**
* Initialize the store by wiring up to the client.
* Must be called once after app startup.
*/
async init(): Promise<void> {
init(): void {
if (!browser) return;
if (this._client) return; // Already initialized
const { agenticClient } = await import('$lib/clients/agentic.client');
this._client = agenticClient;
if (this._initialized) return; // Already initialized
agenticClient.setStoreCallbacks({
setRunning: (convId, running) => this.updateSession(convId, { isRunning: running }),
@ -87,6 +87,8 @@ class AgenticStore {
setStreamingToolCall: (convId, tc) => this.updateSession(convId, { streamingToolCall: tc }),
clearStreamingToolCall: (convId) => this.updateSession(convId, { streamingToolCall: null })
});
this._initialized = true;
}
/**

View File

@ -19,45 +19,19 @@
* @see MCPService in services/mcp.ts for protocol operations
*/
import { mcpClient } from '$lib/clients/mcp.client';
import { mcpClient, buildMcpClientConfig } from '$lib/clients/mcp.client';
import type {
HealthCheckState,
MCPServerSettingsEntry,
McpServerUsageStats,
MCPPromptInfo,
GetPromptResult
} from '$lib/types';
import type { McpServerOverride } from '$lib/types/database';
import { buildMcpClientConfig, parseMcpServerSettings, getMcpServerLabel } from '$lib/utils/mcp';
import { parseMcpServerSettings } from '$lib/utils';
import { HealthCheckStatus } from '$lib/enums';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
/**
* Parses MCP server usage stats from settings.
* @param rawStats - The raw stats to parse
* @returns MCP server usage stats or empty object if invalid
*/
function parseMcpServerUsageStats(rawStats: unknown): McpServerUsageStats {
if (!rawStats) return {};
if (typeof rawStats === 'string') {
const trimmed = rawStats.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as McpServerUsageStats;
}
} catch {
console.warn('[MCP] Failed to parse mcpServerUsageStats JSON, ignoring value');
}
}
return {};
}
class MCPStore {
private _isInitializing = $state(false);
private _error = $state<string | null>(null);
@ -178,6 +152,21 @@ class MCPStore {
return parseMcpServerSettings(config().mcpServers);
}
/**
* Gets a display label for an MCP server.
* Automatically fetches health state from store.
*/
getServerLabel(server: MCPServerSettingsEntry): string {
const healthState = this.getHealthCheckState(server.id);
if (healthState?.status === HealthCheckStatus.Success) {
return (
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
);
}
return server.url;
}
/**
* Check if any server is still loading (idle or connecting).
*/
@ -205,8 +194,8 @@ class MCPStore {
// Sort alphabetically by display label once all health checks are done
return [...servers].sort((a, b) => {
const labelA = getMcpServerLabel(a, this.getHealthCheckState(a.id));
const labelB = getMcpServerLabel(b, this.getHealthCheckState(b.id));
const labelA = this.getServerLabel(a);
const labelB = this.getServerLabel(b);
return labelA.localeCompare(labelB);
});
}
@ -266,33 +255,24 @@ class MCPStore {
}
/**
*
* Server Usage Stats
*
* Gets enabled MCP servers for a conversation based on per-chat overrides.
* Returns servers that are both globally enabled AND enabled for this chat.
*/
getEnabledServersForConversation(
perChatOverrides?: McpServerOverride[]
): MCPServerSettingsEntry[] {
if (!perChatOverrides?.length) {
return [];
}
/**
* Get parsed usage stats for all servers
*/
getUsageStats(): McpServerUsageStats {
return parseMcpServerUsageStats(config().mcpServerUsageStats);
}
const allServers = this.getServers();
/**
* Get usage count for a specific server
*/
getServerUsageCount(serverId: string): number {
const stats = this.getUsageStats();
return stats[serverId] || 0;
}
return allServers.filter((server) => {
if (!server.enabled) return false;
const override = perChatOverrides.find((o) => o.serverId === server.id);
/**
* Increment usage count for a server
*/
incrementServerUsage(serverId: string): void {
const stats = this.getUsageStats();
stats[serverId] = (stats[serverId] || 0) + 1;
settingsStore.updateConfig('mcpServerUsageStats', JSON.stringify(stats));
return override?.enabled ?? false;
});
}
/**

View File

@ -89,7 +89,6 @@ export type {
MCPServerConfig,
MCPClientConfig,
MCPServerSettingsEntry,
McpServerUsageStats,
MCPToolCall,
OpenAIToolDefinition,
ServerStatus,

View File

@ -232,8 +232,6 @@ export interface ServerStatus {
error?: string;
}
export type McpServerUsageStats = Record<string, number>;
export interface MCPServerConnectionConfig {
name: string;
server: MCPServerConfig;

View File

@ -0,0 +1,22 @@
/**
* Favicon utility functions for extracting favicons from URLs.
*/
/**
* Gets a favicon URL for a given URL using Google's favicon service.
* Returns null if the URL is invalid.
*
* @param urlString - The URL to get the favicon for
* @returns The favicon URL or null if invalid
*/
export function getFaviconUrl(urlString: string): string | null {
try {
const url = new URL(urlString);
const hostnameParts = url.hostname.split('.');
const rootDomain = hostnameParts.length >= 2 ? hostnameParts.slice(-2).join('.') : url.hostname;
return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`;
} catch {
return null;
}
}

View File

@ -0,0 +1,44 @@
/**
* Header utilities for parsing and serializing HTTP headers.
* Generic utilities not specific to MCP.
*/
/**
* Parses a JSON string of headers into an array of key-value pairs.
* Returns empty array if the JSON is invalid or empty.
*/
export function parseHeadersToArray(headersJson: string): { key: string; value: string }[] {
if (!headersJson?.trim()) return [];
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([key, value]) => ({
key,
value: String(value)
}));
}
} catch {
return [];
}
return [];
}
/**
* Serializes an array of header key-value pairs to a JSON string.
* Filters out pairs with empty keys and returns empty string if no valid pairs.
*/
export function serializeHeaders(pairs: { key: string; value: string }[]): string {
const validPairs = pairs.filter((p) => p.key.trim());
if (validPairs.length === 0) return '';
const obj: Record<string, string> = {};
for (const pair of validPairs) {
obj[pair.key.trim()] = pair.value;
}
return JSON.stringify(obj);
}

View File

@ -63,7 +63,14 @@ export {
} from './file-type';
// Formatting utilities
export { formatFileSize, formatParameters, formatNumber } from './formatters';
export {
formatFileSize,
formatParameters,
formatNumber,
formatJsonPretty,
formatTime,
formatPerformanceTime
} from './formatters';
// IME utilities
export { isIMEComposing } from './is-ime-composing';
@ -95,7 +102,30 @@ export { getLanguageFromFilename } from './syntax-highlight-language';
export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
// Agentic utilities
export { toAgenticMessages } from './agentic';
export { toAgenticMessages, getAgenticConfig } from './agentic';
// Base64 utilities
export { decodeBase64 } from './base64';
// Chat stream utilities
export { mergeToolCallDeltas, extractModelName } from './chat-stream';
// Debounce utilities
export { debounce } from './debounce';
// Image error fallback utilities
export { getImageErrorFallbackHtml } from './image-error-fallback';
// MCP utilities
export {
detectMcpTransportFromUrl,
parseMcpServerSettings,
getMcpLogLevelIcon,
getMcpLogLevelClass
} from './mcp';
// Header utilities
export { parseHeadersToArray, serializeHeaders } from './headers';
// Favicon utilities
export { getFaviconUrl } from './favicon';

View File

@ -1,12 +1,5 @@
import type {
HealthCheckState,
MCPClientConfig,
MCPServerConfig,
MCPServerSettingsEntry
} from '$lib/types';
import type { SettingsConfigType } from '$lib/types/settings';
import type { McpServerOverride } from '$lib/types/database';
import { MCPTransportType, MCPLogLevel, HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
import { MCPTransportType, MCPLogLevel } from '$lib/enums';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { Info, AlertTriangle, XCircle } from '@lucide/svelte';
import type { Component } from 'svelte';
@ -23,90 +16,6 @@ export function detectMcpTransportFromUrl(url: string): MCPTransportType {
: MCPTransportType.StreamableHttp;
}
/**
* Generates a valid MCP server ID from user input.
* Returns the trimmed ID if valid, otherwise generates 'server-{index+1}'.
*/
export function generateMcpServerId(id: unknown, index: number): string {
if (typeof id === 'string' && id.trim()) {
return id.trim();
}
return `server-${index + 1}`;
}
/**
* Gets a display label for an MCP server based on health state.
*/
export function getMcpServerLabel(
server: MCPServerSettingsEntry,
healthState?: HealthCheckState
): string {
if (healthState?.status === HealthCheckStatus.Success) {
return (
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
);
}
return server.url;
}
/**
* Gets a favicon URL for an MCP server using Google's favicon service.
* Returns null if the URL is invalid.
*/
export function getFaviconUrl(serverUrl: string): string | null {
try {
const parsedUrl = new URL(serverUrl);
const hostnameParts = parsedUrl.hostname.split('.');
const rootDomain =
hostnameParts.length >= 2 ? hostnameParts.slice(-2).join('.') : parsedUrl.hostname;
return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`;
} catch {
return null;
}
}
/**
* Parses a JSON string of headers into an array of key-value pairs.
* Returns empty array if the JSON is invalid or empty.
*/
export function parseHeadersToArray(headersJson: string): { key: string; value: string }[] {
if (!headersJson?.trim()) return [];
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([key, value]) => ({
key,
value: String(value)
}));
}
} catch {
return [];
}
return [];
}
/**
* Serializes an array of header key-value pairs to a JSON string.
* Filters out pairs with empty keys and returns empty string if no valid pairs.
*/
export function serializeHeaders(pairs: { key: string; value: string }[]): string {
const validPairs = pairs.filter((p) => p.key.trim());
if (validPairs.length === 0) return '';
const obj: Record<string, string> = {};
for (const pair of validPairs) {
obj[pair.key.trim()] = pair.value;
}
return JSON.stringify(obj);
}
/**
* Parses MCP server settings from a JSON string or array.
* requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
@ -136,9 +45,13 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
return parsed.map((entry, index) => {
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
const id =
typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim()
? (entry as { id: string }).id.trim()
: `server-${index + 1}`;
return {
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
id,
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
url,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
@ -147,122 +60,6 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
});
}
/**
* Builds an MCP server configuration from a server settings entry.
* @param entry - The server settings entry to build the configuration from
* @param connectionTimeoutMs - The connection timeout in milliseconds
* @returns The built server configuration, or undefined if the entry is invalid
*/
function buildServerConfig(
entry: MCPServerSettingsEntry,
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
): MCPServerConfig | undefined {
if (!entry?.url) {
return undefined;
}
let headers: Record<string, string> | undefined;
if (entry.headers) {
try {
const parsed = JSON.parse(entry.headers);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
headers = parsed as Record<string, string>;
}
} catch {
console.warn('[MCP] Failed to parse custom headers JSON, ignoring:', entry.headers);
}
}
return {
url: entry.url,
transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
headers
};
}
/**
* Checks if a server is enabled for the current chat.
* Server must be available (server.enabled) AND have a per-chat override enabling it.
* Pure helper function - no side effects.
*/
export function checkServerEnabled(
server: MCPServerSettingsEntry,
perChatOverrides?: McpServerOverride[]
): boolean {
// Server must be available in settings first
if (!server.enabled) {
return false;
}
// Then check if it's enabled for this chat via override
if (perChatOverrides) {
const override = perChatOverrides.find((o) => o.serverId === server.id);
return override?.enabled ?? false;
}
return false;
}
/**
* Builds MCP client configuration from settings.
* Returns undefined if no valid servers are configured.
* @param config - Global settings configuration
* @param perChatOverrides - Optional per-chat server overrides
*/
export function buildMcpClientConfig(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseMcpServerSettings(config.mcpServers);
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;
}
return {
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
capabilities: DEFAULT_MCP_CONFIG.capabilities,
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
servers
};
}
/**
* Gets enabled MCP servers for a conversation based on per-chat overrides.
* Returns servers that are both globally enabled AND enabled for this chat.
* @param config - Global settings configuration
* @param perChatOverrides - Per-chat server overrides
* @returns Array of enabled server settings entries
*/
export function getEnabledServersForConversation(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPServerSettingsEntry[] {
if (!perChatOverrides?.length) {
return [];
}
const allServers = parseMcpServerSettings(config.mcpServers);
return allServers.filter((server) => checkServerEnabled(server, perChatOverrides));
}
/**
* Get the appropriate icon component for a log level
*