refactor: Cleanup

This commit is contained in:
Aleksander Grygier 2026-02-11 09:14:22 +01:00
parent d22760598d
commit 2bc1111f8d
18 changed files with 283 additions and 130 deletions

View File

@ -5,6 +5,7 @@
import { ChatMessageStatsView } from '$lib/enums';
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants/formatters';
interface Props {
predictedTokens?: number;
@ -65,14 +66,16 @@
predictedMs > 0
);
let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
let tokensPerSecond = $derived(
hasGenerationStats ? (predictedTokens! / predictedMs!) * MS_PER_SECOND : 0
);
let formattedTime = $derived(
predictedMs !== undefined ? formatPerformanceTime(predictedMs) : '0s'
predictedMs !== undefined ? formatPerformanceTime(predictedMs) : DEFAULT_PERFORMANCE_TIME
);
let promptTokensPerSecond = $derived(
promptTokens !== undefined && promptMs !== undefined && promptMs > 0
? (promptTokens / promptMs) * 1000
? (promptTokens / promptMs) * MS_PER_SECOND
: undefined
);
@ -94,12 +97,12 @@
let agenticToolsPerSecond = $derived(
hasAgenticStats && agenticTimings!.toolsMs > 0
? (agenticTimings!.toolCallsCount / agenticTimings!.toolsMs) * 1000
? (agenticTimings!.toolCallsCount / agenticTimings!.toolsMs) * MS_PER_SECOND
: 0
);
let formattedAgenticToolsTime = $derived(
hasAgenticStats ? formatPerformanceTime(agenticTimings!.toolsMs) : '0s'
hasAgenticStats ? formatPerformanceTime(agenticTimings!.toolsMs) : DEFAULT_PERFORMANCE_TIME
);
let agenticTotalTimeMs = $derived(

View File

@ -20,10 +20,12 @@
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { setMode } from 'mode-watcher';
import { ColorMode } from '$lib/enums/ui';
import { SettingsFieldType } from '$lib/enums/settings';
import type { Component } from 'svelte';
import { NUMERIC_FIELDS, POSITIVE_INTEGER_FIELDS } from '$lib/constants/settings-fields';
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
import { SETTINGS_SECTION_TITLES } from '$lib/constants/settings-sections';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import type { SettingsSectionTitle } from '$lib/constants/settings-sections';
interface Props {
@ -43,42 +45,42 @@
icon: Settings,
fields: [
{
key: 'theme',
key: SETTINGS_KEYS.THEME,
label: 'Theme',
type: 'select',
type: SettingsFieldType.SELECT,
options: SETTINGS_COLOR_MODES_CONFIG
},
{ key: 'apiKey', label: 'API Key', type: 'input' },
{ key: SETTINGS_KEYS.API_KEY, label: 'API Key', type: SettingsFieldType.INPUT },
{
key: 'systemMessage',
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
label: 'System Message',
type: 'textarea'
type: SettingsFieldType.TEXTAREA
},
{
key: 'pasteLongTextToFileLen',
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
label: 'Paste long text to file length',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'copyTextAttachmentsAsPlainText',
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
label: 'Copy text attachments as plain text',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'enableContinueGeneration',
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
label: 'Enable "Continue" button',
type: 'checkbox',
type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
key: 'pdfAsImage',
key: SETTINGS_KEYS.PDF_AS_IMAGE,
label: 'Parse PDF as image',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'askForTitleConfirmation',
key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
label: 'Ask for confirmation before changing conversation title',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
}
]
},
@ -87,45 +89,45 @@
icon: Monitor,
fields: [
{
key: 'showMessageStats',
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
label: 'Show message generation statistics',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'showThoughtInProgress',
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
label: 'Show thought in progress',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'keepStatsVisible',
key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
label: 'Keep stats visible after generation',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'autoMicOnEmpty',
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input',
type: 'checkbox',
type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
key: 'renderUserContentAsMarkdown',
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'disableAutoScroll',
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'alwaysShowSidebarOnDesktop',
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
label: 'Always show sidebar on desktop',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'autoShowSidebarOnNewChat',
key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
label: 'Auto-show sidebar on new chat',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
}
]
},
@ -134,64 +136,64 @@
icon: Funnel,
fields: [
{
key: 'temperature',
key: SETTINGS_KEYS.TEMPERATURE,
label: 'Temperature',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'dynatemp_range',
key: SETTINGS_KEYS.DYNATEMP_RANGE,
label: 'Dynamic temperature range',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'dynatemp_exponent',
key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
label: 'Dynamic temperature exponent',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'top_k',
key: SETTINGS_KEYS.TOP_K,
label: 'Top K',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'top_p',
key: SETTINGS_KEYS.TOP_P,
label: 'Top P',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'min_p',
key: SETTINGS_KEYS.MIN_P,
label: 'Min P',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'xtc_probability',
key: SETTINGS_KEYS.XTC_PROBABILITY,
label: 'XTC probability',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'xtc_threshold',
key: SETTINGS_KEYS.XTC_THRESHOLD,
label: 'XTC threshold',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'typ_p',
key: SETTINGS_KEYS.TYP_P,
label: 'Typical P',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'max_tokens',
key: SETTINGS_KEYS.MAX_TOKENS,
label: 'Max tokens',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'samplers',
key: SETTINGS_KEYS.SAMPLERS,
label: 'Samplers',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'backend_sampling',
key: SETTINGS_KEYS.BACKEND_SAMPLING,
label: 'Backend sampling',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
}
]
},
@ -200,44 +202,44 @@
icon: AlertTriangle,
fields: [
{
key: 'repeat_last_n',
key: SETTINGS_KEYS.REPEAT_LAST_N,
label: 'Repeat last N',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'repeat_penalty',
key: SETTINGS_KEYS.REPEAT_PENALTY,
label: 'Repeat penalty',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'presence_penalty',
key: SETTINGS_KEYS.PRESENCE_PENALTY,
label: 'Presence penalty',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'frequency_penalty',
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
label: 'Frequency penalty',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'dry_multiplier',
key: SETTINGS_KEYS.DRY_MULTIPLIER,
label: 'DRY multiplier',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'dry_base',
key: SETTINGS_KEYS.DRY_BASE,
label: 'DRY base',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'dry_allowed_length',
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
label: 'DRY allowed length',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'dry_penalty_last_n',
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
label: 'DRY penalty last N',
type: 'input'
type: SettingsFieldType.INPUT
}
]
},
@ -251,24 +253,24 @@
icon: McpLogo,
fields: [
{
key: 'agenticMaxTurns',
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
label: 'Agentic loop max turns',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'alwaysShowAgenticTurns',
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
label: 'Always show agentic turns in conversation',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'agenticMaxToolPreviewLines',
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
label: 'Max lines per tool preview',
type: 'input'
type: SettingsFieldType.INPUT
},
{
key: 'showToolCallInProgress',
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
label: 'Show tool call in progress',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
}
]
},
@ -277,24 +279,19 @@
icon: Code,
fields: [
{
key: 'disableReasoningParsing',
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
label: 'Disable reasoning content parsing',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'showRawOutputSwitch',
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
label: 'Enable raw output toggle',
type: 'checkbox'
type: SettingsFieldType.CHECKBOX
},
{
key: 'showRawOutputSwitch',
label: 'Enable raw output toggle',
type: 'checkbox'
},
{
key: 'custom',
key: SETTINGS_KEYS.CUSTOM,
label: 'Custom JSON',
type: 'textarea'
type: SettingsFieldType.TEXTAREA
}
]
}

View File

@ -6,6 +6,8 @@
import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { SettingsFieldType } from '$lib/enums/settings';
import { settingsStore } from '$lib/stores/settings.svelte';
import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
import type { Component } from 'svelte';
@ -31,7 +33,7 @@
{#each fields as field (field.key)}
<div class="space-y-2">
{#if field.type === 'input'}
{#if field.type === SettingsFieldType.INPUT}
{@const paramInfo = getParameterSourceInfo(field.key)}
{@const currentValue = String(localConfig[field.key] ?? '')}
{@const propsDefault = paramInfo?.serverDefault}
@ -98,7 +100,7 @@
{@html field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
{:else if field.type === 'textarea'}
{:else if field.type === SettingsFieldType.TEXTAREA}
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
{field.label}
@ -121,7 +123,7 @@
</p>
{/if}
{#if field.key === 'systemMessage'}
{#if field.key === SETTINGS_KEYS.SYSTEM_MESSAGE}
<div class="mt-3 flex items-center gap-2">
<Checkbox
id="showSystemMessage"
@ -134,7 +136,7 @@
</Label>
</div>
{/if}
{:else if field.type === 'select'}
{:else if field.type === SettingsFieldType.SELECT}
{@const selectedOption = field.options?.find(
(opt: { value: string; label: string; icon?: Component }) =>
opt.value === localConfig[field.key]
@ -166,7 +168,7 @@
type="single"
value={currentValue}
onValueChange={(value) => {
if (field.key === 'theme' && value && onThemeChange) {
if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
onThemeChange(value);
} else {
onConfigChange(field.key, value);
@ -222,7 +224,7 @@
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
{:else if field.type === 'checkbox'}
{:else if field.type === SettingsFieldType.CHECKBOX}
<div class="flex items-start space-x-3">
<Checkbox
id={field.key}

View File

@ -367,8 +367,10 @@
if (appendMode && index < previousBlockCount) {
const prevBlock = renderedBlocks[index];
const currentHash = getMdastNodeHash(child, index);
if (prevBlock?.contentHash === currentHash) {
nextBlocks.push(prevBlock);
continue;
}
}
@ -379,6 +381,7 @@
{ position: (child as { position?: unknown }).position } as HastRootContent,
index
);
nextBlocks.push({ id, html, contentHash: hash });
}
@ -390,6 +393,7 @@
previousContent = prefixMarkdown;
unstableBlockHtml = '';
incompleteCodeBlock = incompleteBlock;
return;
}
@ -416,6 +420,7 @@
const currentHash = getMdastNodeHash(child, index);
if (prevBlock?.contentHash === currentHash) {
nextBlocks.push(prevBlock);
continue;
}
}

View File

@ -28,7 +28,7 @@
if (extra.mimeType?.includes(MimeTypeIncludes.JSON)) return 'json';
if (extra.mimeType?.includes(MimeTypeIncludes.JAVASCRIPT)) return 'javascript';
if (extra.mimeType?.includes(MimeTypeIncludes.TYPESCRIPT)) return 'typescript';
// Try to detect from URI/name
const name = extra.name || extra.uri || '';
return getLanguageFromFilename(name) || 'plaintext';
}

View File

@ -63,6 +63,7 @@ export function buildResourceTree(
}
const fileName = pathParts[pathParts.length - 1] || resource.name;
current.children.set(resource.uri, {
name: fileName,
resource: { ...resource, serverName },
@ -71,7 +72,6 @@ export function buildResourceTree(
});
}
// Clean up empty folders that don't match
function cleanupEmptyFolders(node: ResourceTreeNode): boolean {
if (node.resource) return true;

View File

@ -4,6 +4,11 @@ export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/;
export const NEWLINE_SEPARATOR = '\n';
export const TURN_LIMIT_MESSAGE = '\n\n```\nTurn limit reached\n```\n';
export const LLM_ERROR_BLOCK_START = '\n\n```\nUpstream LLM error:\n';
export const LLM_ERROR_BLOCK_END = '\n```\n';
export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
enabled: true,
maxTurns: 100,

View File

@ -27,6 +27,18 @@ export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000;
*/
export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50;
/**
* Maximum number of MCP resources to cache
* @default 50
*/
export const MCP_RESOURCE_CACHE_MAX_ENTRIES = 50;
/**
* TTL for MCP resource cache entries in milliseconds
* @default 5 minutes
*/
export const MCP_RESOURCE_CACHE_TTL_MS = 5 * 60 * 1000;
/**
* Maximum number of inactive conversation states to keep in memory
* States for conversations beyond this limit will be cleaned up

View File

@ -3,3 +3,6 @@ export const SECONDS_PER_MINUTE = 60;
export const SECONDS_PER_HOUR = 3600;
export const SHORT_DURATION_THRESHOLD = 1;
export const MEDIUM_DURATION_THRESHOLD = 10;
/** Default display value when no performance time is available */
export const DEFAULT_PERFORMANCE_TIME = '0s';

View File

@ -12,6 +12,21 @@ export const PROTOCOL_PREFIX_REGEX = /^[a-z]+:\/\//;
// File extension regex for display name extraction
export const FILE_EXTENSION_REGEX = /\.[^.]+$/;
// Separator regex for splitting display names (kebab-case/snake_case)
export const DISPLAY_NAME_SEPARATOR_REGEX = /[-_]/;
// Regex for matching base64-encoded data URIs
export const DATA_URI_BASE64_REGEX = /^data:([^;]+);base64,([A-Za-z0-9+/]+=*)$/;
// Prefix for MCP attachment filenames
export const MCP_ATTACHMENT_NAME_PREFIX = 'mcp-attachment';
// Prefix for MCP resource attachment IDs
export const MCP_RESOURCE_ATTACHMENT_ID_PREFIX = 'res';
// Default file extension for unknown image types
export const DEFAULT_IMAGE_EXTENSION = 'img';
/**
* Mapping from image MIME types to file extensions.
* Used for generating attachment filenames from MIME types.

View File

@ -0,0 +1,57 @@
/**
* Settings key constants for ChatSettings configuration.
*
* These keys correspond to properties in SettingsConfigType and are used
* in settings field configurations to ensure consistency.
*/
export const SETTINGS_KEYS = {
// General
THEME: 'theme',
API_KEY: 'apiKey',
SYSTEM_MESSAGE: 'systemMessage',
PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen',
COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText',
ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration',
PDF_AS_IMAGE: 'pdfAsImage',
ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation',
// Display
SHOW_MESSAGE_STATS: 'showMessageStats',
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
KEEP_STATS_VISIBLE: 'keepStatsVisible',
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
AUTO_SHOW_SIDEBAR_ON_NEW_CHAT: 'autoShowSidebarOnNewChat',
// Sampling
TEMPERATURE: 'temperature',
DYNATEMP_RANGE: 'dynatemp_range',
DYNATEMP_EXPONENT: 'dynatemp_exponent',
TOP_K: 'top_k',
TOP_P: 'top_p',
MIN_P: 'min_p',
XTC_PROBABILITY: 'xtc_probability',
XTC_THRESHOLD: 'xtc_threshold',
TYP_P: 'typ_p',
MAX_TOKENS: 'max_tokens',
SAMPLERS: 'samplers',
BACKEND_SAMPLING: 'backend_sampling',
// Penalties
REPEAT_LAST_N: 'repeat_last_n',
REPEAT_PENALTY: 'repeat_penalty',
PRESENCE_PENALTY: 'presence_penalty',
FREQUENCY_PENALTY: 'frequency_penalty',
DRY_MULTIPLIER: 'dry_multiplier',
DRY_BASE: 'dry_base',
DRY_ALLOWED_LENGTH: 'dry_allowed_length',
DRY_PENALTY_LAST_N: 'dry_penalty_last_n',
// MCP
AGENTIC_MAX_TURNS: 'agenticMaxTurns',
ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns',
AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
// Developer
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
CUSTOM: 'custom'
} as const;

View File

@ -44,7 +44,7 @@ export { ModelModality } from './model';
export { ServerRole, ServerModelStatus } from './server';
export { ParameterSource, SyncableParameterType } from './settings';
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
export { ColorMode, McpPromptVariant, UrlPrefix } from './ui';

View File

@ -14,3 +14,13 @@ export enum SyncableParameterType {
STRING = 'string',
BOOLEAN = 'boolean'
}
/**
* Settings field type - defines the input type for settings fields
*/
export enum SettingsFieldType {
INPUT = 'input',
TEXTAREA = 'textarea',
CHECKBOX = 'checkbox',
SELECT = 'select'
}

View File

@ -22,9 +22,21 @@ import { config } from '$lib/stores/settings.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { isAbortError } from '$lib/utils';
import { DEFAULT_AGENTIC_CONFIG, AGENTIC_TAGS } from '$lib/constants/agentic';
import { IMAGE_MIME_TO_EXTENSION } from '$lib/constants/mcp-resource';
import { AttachmentType, ContentPartType, MessageRole } from '$lib/enums';
import {
DEFAULT_AGENTIC_CONFIG,
AGENTIC_TAGS,
NEWLINE_SEPARATOR,
TURN_LIMIT_MESSAGE,
LLM_ERROR_BLOCK_START,
LLM_ERROR_BLOCK_END
} from '$lib/constants/agentic';
import {
IMAGE_MIME_TO_EXTENSION,
DATA_URI_BASE64_REGEX,
MCP_ATTACHMENT_NAME_PREFIX,
DEFAULT_IMAGE_EXTENSION
} from '$lib/constants/mcp-resource';
import { AttachmentType, ContentPartType, MessageRole, MimeTypePrefix } from '$lib/enums';
import type {
AgenticFlowParams,
AgenticFlowResult,
@ -425,7 +437,7 @@ class AgenticStore {
return;
}
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
onChunk?.(`\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`);
onChunk?.(`${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`);
onComplete?.(
'',
undefined,
@ -573,7 +585,7 @@ class AgenticStore {
if (turnStats.toolCalls.length > 0) agenticTimings.perTurn!.push(turnStats);
}
onChunk?.('\n\n```\nTurn limit reached\n```\n');
onChunk?.(TURN_LIMIT_MESSAGE);
onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
}
@ -611,7 +623,7 @@ class AgenticStore {
}
let output = `\n${AGENTIC_TAGS.TOOL_ARGS_END}`;
const lines = result.split('\n');
const lines = result.split(NEWLINE_SEPARATOR);
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
output += `\n${trimmedLines.join('\n')}\n${AGENTIC_TAGS.TOOL_CALL_END}\n`;
@ -626,14 +638,14 @@ class AgenticStore {
return { cleanedResult: result, attachments: [] };
}
const lines = result.split('\n');
const lines = result.split(NEWLINE_SEPARATOR);
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+/]+=*)$/);
const match = trimmedLine.match(DATA_URI_BASE64_REGEX);
if (!match) {
return line;
}
@ -648,7 +660,7 @@ class AgenticStore {
attachmentIndex += 1;
const name = this.buildAttachmentName(mimeType, attachmentIndex);
if (mimeType.startsWith('image/')) {
if (mimeType.startsWith(MimeTypePrefix.IMAGE)) {
attachments.push({ type: AttachmentType.IMAGE, name, base64Url: trimmedLine });
return `[Attachment saved: ${name}]`;
@ -657,12 +669,12 @@ class AgenticStore {
return line;
});
return { cleanedResult: cleanedLines.join('\n'), attachments };
return { cleanedResult: cleanedLines.join(NEWLINE_SEPARATOR), attachments };
}
private buildAttachmentName(mimeType: string, index: number): string {
const extension = IMAGE_MIME_TO_EXTENSION[mimeType] ?? 'img';
return `mcp-attachment-${Date.now()}-${index}.${extension}`;
const extension = IMAGE_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_IMAGE_EXTENSION;
return `${MCP_ATTACHMENT_NAME_PREFIX}-${Date.now()}-${index}.${extension}`;
}
}

View File

@ -12,6 +12,8 @@
import { SvelteMap } from 'svelte/reactivity';
import { AttachmentType } from '$lib/enums';
import { MCP_RESOURCE_CACHE_MAX_ENTRIES, MCP_RESOURCE_CACHE_TTL_MS } from '$lib/constants/cache';
import { MCP_RESOURCE_ATTACHMENT_ID_PREFIX } from '$lib/constants/mcp-resource';
import type {
MCPResource,
MCPResourceTemplate,
@ -24,11 +26,8 @@ import type {
MCPServerResources
} from '$lib/types';
const MAX_CACHED_RESOURCES = 50;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
function generateAttachmentId(): string {
return `res-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
return `${MCP_RESOURCE_ATTACHMENT_ID_PREFIX}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
class MCPResourceStore {
@ -241,9 +240,10 @@ class MCPResourceStore {
*/
cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void {
// Enforce cache size limit
if (this._cachedResources.size >= MAX_CACHED_RESOURCES) {
if (this._cachedResources.size >= MCP_RESOURCE_CACHE_MAX_ENTRIES) {
// Remove oldest entry
const oldestKey = this._cachedResources.keys().next().value;
if (oldestKey) {
this._cachedResources.delete(oldestKey);
}
@ -267,7 +267,8 @@ class MCPResourceStore {
// Check if cache is still valid
const age = Date.now() - cached.fetchedAt.getTime();
if (age > CACHE_TTL_MS && !cached.subscribed) {
if (age > MCP_RESOURCE_CACHE_TTL_MS && !cached.subscribed) {
// Cache expired and not subscribed, remove it
this._cachedResources.delete(uri);

View File

@ -79,6 +79,7 @@ function parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
parsed = JSON.parse(trimmed);
} catch (error) {
console.warn('[MCP] Failed to parse mcpServers JSON:', error);
return [];
}
} else {
@ -91,6 +92,7 @@ function parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
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),
@ -121,6 +123,7 @@ function buildServerConfig(
console.warn('[MCP] Failed to parse custom headers JSON:', entry.headers);
}
}
return {
url: entry.url,
transport: detectMcpTransportFromUrl(entry.url),
@ -141,8 +144,10 @@ function checkServerEnabled(
if (perChatOverrides) {
const override = perChatOverrides.find((o) => o.serverId === server.id);
return override?.enabled ?? false;
}
return false;
}
@ -156,11 +161,13 @@ function buildMcpClientConfigInternal(
}
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;
}
@ -228,27 +235,34 @@ class MCPStore {
get isInitializing(): boolean {
return this._isInitializing;
}
get isInitialized(): boolean {
return this.connections.size > 0;
}
get error(): string | null {
return this._error;
}
get toolCount(): number {
return this._toolCount;
}
get connectedServerCount(): number {
return this._connectedServers.length;
}
get connectedServerNames(): string[] {
return this._connectedServers;
}
get isEnabled(): boolean {
const mcpConfig = buildMcpClientConfigInternal(config());
return (
mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
);
}
get availableTools(): string[] {
return Array.from(this.toolsIndex.keys());
}
@ -259,29 +273,45 @@ class MCPStore {
toolCount?: number;
connectedServers?: string[];
}): void {
if (state.isInitializing !== undefined) this._isInitializing = state.isInitializing;
if (state.error !== undefined) this._error = state.error;
if (state.toolCount !== undefined) this._toolCount = state.toolCount;
if (state.connectedServers !== undefined) this._connectedServers = state.connectedServers;
if (state.isInitializing !== undefined) {
this._isInitializing = state.isInitializing;
}
if (state.error !== undefined) {
this._error = state.error;
}
if (state.toolCount !== undefined) {
this._toolCount = state.toolCount;
}
if (state.connectedServers !== undefined) {
this._connectedServers = state.connectedServers;
}
}
updateHealthCheck(serverId: string, state: HealthCheckState): void {
this._healthChecks = { ...this._healthChecks, [serverId]: state };
}
getHealthCheckState(serverId: string): HealthCheckState {
return this._healthChecks[serverId] ?? { status: 'idle' };
}
hasHealthCheck(serverId: string): boolean {
return serverId in this._healthChecks && this._healthChecks[serverId].status !== 'idle';
}
clearHealthCheck(serverId: string): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [serverId]: _removed, ...rest } = this._healthChecks;
this._healthChecks = rest;
}
clearAllHealthChecks(): void {
this._healthChecks = {};
}
clearError(): void {
this._error = null;
}

View File

@ -2,14 +2,14 @@ import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
import type { OpenAIToolDefinition } from './mcp';
import type { DatabaseMessageExtra } from './database';
import type { ParameterSource, SyncableParameterType } from '$lib/enums';
import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
export type SettingsConfigValue = string | number | boolean;
export interface SettingsFieldConfig {
key: string;
label: string;
type: 'input' | 'textarea' | 'checkbox' | 'select';
type: SettingsFieldType;
isExperimental?: boolean;
help?: string;
options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;

View File

@ -14,7 +14,8 @@ import {
CODE_FILE_EXTENSION_REGEX,
TEXT_FILE_EXTENSION_REGEX,
PROTOCOL_PREFIX_REGEX,
FILE_EXTENSION_REGEX
FILE_EXTENSION_REGEX,
DISPLAY_NAME_SEPARATOR_REGEX
} from '$lib/constants/mcp-resource';
import {
Database,
@ -159,7 +160,7 @@ export function parseResourcePath(uri: string): string[] {
export function getDisplayName(pathPart: string): string {
const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, '');
return withoutExt
.split(/[-_]/)
.split(DISPLAY_NAME_SEPARATOR_REGEX)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}