ui: Linting & Formatting scripts (#26819)

This commit is contained in:
Aleksander Grygier
2026-08-10 08:38:37 +02:00
committed by GitHub
parent 1e396e72a8
commit 92d1bb0c99
538 changed files with 8806 additions and 6036 deletions
+205 -121
View File
@@ -1,23 +1,21 @@
import { getAuthHeaders, getJsonHeaders } from '$lib/utils/api-headers';
import { formatAttachmentText } from '$lib/utils/formatters';
import { isAbortError } from '$lib/utils/abort';
import { streamIdentity } from '$lib/utils/stream-identity';
import { settingsStore } from '../stores/settings.svelte';
import { capImageDataURLSize } from '../utils/cap-img-size';
import {
ATTACHMENT_LABEL_PDF_FILE,
API_CHAT,
API_SLOTS,
API_STREAM,
ATTACHMENT_LABEL_MCP_PROMPT,
ATTACHMENT_LABEL_MCP_RESOURCE,
ATTACHMENT_LABEL_PDF_FILE,
CONTROL_ACTION,
LEGACY_AGENTIC_REGEX,
REASONING_EFFORT_TOKENS,
SETTINGS_KEYS,
API_CHAT,
API_SLOTS,
CONTROL_ACTION,
SSE_LINE_SEPARATOR,
SSE_DATA_PREFIX,
SSE_DONE_MARKER,
STREAM_VISIBILITY_KICK_MS,
SSE_LINE_SEPARATOR,
STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX,
API_STREAM
STREAM_VISIBILITY_KICK_MS
} from '$lib/constants';
import {
AttachmentType,
@@ -28,20 +26,22 @@ import {
ReasoningFormat,
StreamConnectionState
} from '$lib/enums';
import type {
ApiChatMessageContentPart,
ApiChatMessageData,
ApiChatCompletionToolCall,
ApiStreamSession
} from '$lib/types/api';
import { modelsStore } from '$lib/stores/models.svelte';
import type {
AudioInputFormat,
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource
} from '$lib/types';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '../stores/settings.svelte';
import { capImageDataURLSize } from '../utils/cap-img-size';
import type {
ApiChatCompletionToolCall,
ApiChatMessageContentPart,
ApiChatMessageData,
ApiStreamSession
} from '$lib/types/api';
import { isAbortError } from '$lib/utils/abort';
import { getAuthHeaders, getJsonHeaders } from '$lib/utils/api-headers';
import { formatAttachmentText } from '$lib/utils/formatters';
import { streamIdentity } from '$lib/utils/stream-identity';
function getAudioInputFormat(mimeType: string): AudioInputFormat {
const normalizedMimeType = mimeType.trim().toLowerCase();
@@ -98,16 +98,17 @@ export class ChatService {
signal?: AbortSignal
): Promise<string> {
let titleResponse = '';
try {
await ChatService.sendMessage(
[message],
{
model: model || undefined,
stream: true,
custom: { chat_template_kwargs: { enable_thinking: false } },
model: model || undefined,
onChunk: (chunk: string) => {
titleResponse += chunk;
}
},
stream: true
},
undefined,
signal
@@ -115,6 +116,7 @@ export class ChatService {
} catch {
return '';
}
return titleResponse;
}
@@ -143,52 +145,51 @@ export class ChatService {
signal?: AbortSignal
): Promise<string | void> {
const {
stream,
onChunk,
onComplete,
onError,
onConnectionState,
onReasoningChunk,
onToolCallChunk,
onModel,
onCompletionId,
onTimings,
// Tools for function calling
tools,
// Generation parameters
temperature,
max_tokens,
backend_sampling,
continueFinalMessage,
custom,
// Config options
disableReasoningParsing,
dry_allowed_length,
dry_base,
dry_multiplier,
dry_penalty_last_n,
dynatemp_exponent,
// Sampling parameters
dynatemp_range,
dynatemp_exponent,
top_k,
top_p,
enableThinking,
excludeReasoningFromContext,
frequency_penalty,
max_tokens,
min_p,
xtc_probability,
xtc_threshold,
typ_p,
onChunk,
onComplete,
onCompletionId,
onConnectionState,
onError,
onModel,
onReasoningChunk,
onTimings,
onToolCallChunk,
presence_penalty,
reasoningEffort,
// Penalty parameters
repeat_last_n,
repeat_penalty,
presence_penalty,
frequency_penalty,
dry_multiplier,
dry_base,
dry_allowed_length,
dry_penalty_last_n,
// Other parameters
samplers,
backend_sampling,
custom,
stream,
// Generation parameters
temperature,
timings_per_token,
// Config options
disableReasoningParsing,
excludeReasoningFromContext,
enableThinking,
reasoningEffort,
continueFinalMessage
// Tools for function calling
tools,
top_k,
top_p,
typ_p,
xtc_probability,
xtc_threshold
} = options;
const normalizedMessages: ApiChatMessageData[] = (
await Promise.all(
messages.map((msg) => {
@@ -227,6 +228,7 @@ export class ChatService {
return true;
});
// If only text remains and it's a single part, simplify to string
if (
msg.content.length === 1 &&
@@ -242,20 +244,22 @@ export class ChatService {
const requestBody: ApiChatCompletionRequest = {
messages: normalizedMessages.map((msg: ApiChatMessageData) => {
const mapped: ApiChatCompletionRequest['messages'][0] = {
role: msg.role,
content: msg.content,
tool_calls: msg.tool_calls,
tool_call_id: msg.tool_call_id
role: msg.role,
tool_call_id: msg.tool_call_id,
tool_calls: msg.tool_calls
};
// Include reasoning_content from the dedicated field
if (!excludeReasoningFromContext && msg.reasoning_content) {
mapped.reasoning_content = msg.reasoning_content;
}
return mapped;
}),
stream,
return_progress: stream ? true : undefined,
sse_ping_interval: stream ? 1 : undefined,
stream,
tools: tools && tools.length > 0 ? tools : undefined
};
@@ -293,27 +297,42 @@ export class ChatService {
}
if (temperature !== undefined) requestBody.temperature = temperature;
if (max_tokens !== undefined) {
// Set max_tokens to -1 (infinite) when explicitly configured as 0 or null
requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1;
}
if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range;
if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent;
if (top_k !== undefined) requestBody.top_k = top_k;
if (top_p !== undefined) requestBody.top_p = top_p;
if (min_p !== undefined) requestBody.min_p = min_p;
if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability;
if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold;
if (typ_p !== undefined) requestBody.typ_p = typ_p;
if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n;
if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty;
if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty;
if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty;
if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier;
if (dry_base !== undefined) requestBody.dry_base = dry_base;
if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length;
if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n;
if (samplers !== undefined) {
@@ -330,6 +349,7 @@ export class ChatService {
if (custom) {
try {
const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
Object.assign(requestBody, customParams);
} catch (error) {
console.warn('Failed to parse custom parameters:', error);
@@ -338,6 +358,7 @@ export class ChatService {
try {
const headers: Record<string, string> = { ...getJsonHeaders() };
// tag streaming requests with the conversation id, this single header is the opt in for the
// server side replay buffer and powers discoverActiveStream on tab reopen. with an explicit
// model the ::model suffix keeps the per model session distinct
@@ -349,9 +370,9 @@ export class ChatService {
}
const response = await fetch(API_CHAT.COMPLETIONS, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
headers,
method: 'POST',
signal
});
@@ -361,6 +382,7 @@ export class ChatService {
if (conversationId) {
ChatService.clearStreamState(conversationId);
}
const error = await ChatService.parseErrorResponse(response);
if (onError) {
@@ -400,6 +422,7 @@ export class ChatService {
} catch (error) {
if (isAbortError(error)) {
console.log('Chat completion request was aborted');
return;
}
@@ -448,9 +471,11 @@ export class ChatService {
try {
const url = model ? `${API_SLOTS.LIST}?model=${encodeURIComponent(model)}` : API_SLOTS.LIST;
const res = await fetch(url, { signal });
if (!res.ok) return true;
const slots: { is_processing: boolean }[] = await res.json();
return slots.every((s) => !s.is_processing);
} catch {
return true;
@@ -469,34 +494,39 @@ export class ChatService {
console.error(
'stopReasoning: no completion id for the active message, cannot target the running completion'
);
return false;
}
const body: Record<string, unknown> = {
id: completionId,
action: CONTROL_ACTION.END_REASONING
action: CONTROL_ACTION.END_REASONING,
id: completionId
};
if (model) body.model = model;
try {
const res = await fetch(API_CHAT.CONTROL, {
method: 'POST',
body: JSON.stringify(body),
headers: getJsonHeaders(),
body: JSON.stringify(body)
method: 'POST'
});
const data = await res.json().catch(() => null);
if (!res.ok || data?.success !== true) {
console.error('stopReasoning: control request failed', {
status: res.status,
completionId,
response: data
response: data,
status: res.status
});
return false;
}
return true;
} catch (error) {
console.error('stopReasoning: control request threw', { completionId, error });
return false;
}
}
@@ -518,11 +548,13 @@ export class ChatService {
*/
static async cancelServerStream(conversationId: string, model?: string | null): Promise<void> {
if (!conversationId) return;
try {
const id = streamIdentity(conversationId, model);
await fetch(`${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: getAuthHeaders()
headers: getAuthHeaders(),
method: 'DELETE'
});
} catch (e) {
console.warn('cancelServerStream failed:', e);
@@ -545,10 +577,13 @@ export class ChatService {
if (!Array.isArray(sessions) || sessions.length === 0) {
return null;
}
const running = sessions.filter((s) => !s.is_done);
if (running.length === 0) {
return null;
}
return running.reduce((best, cur) => (cur.started_at > best.started_at ? cur : best));
}
@@ -560,12 +595,14 @@ export class ChatService {
model?: string | null
): void {
if (!conversationId) return;
try {
const state: ResumableStreamState = {
bytesReceived,
updatedAt: Date.now(),
model: model ?? null
model: model ?? null,
updatedAt: Date.now()
};
localStorage.setItem(streamStorageKey(conversationId), JSON.stringify(state));
} catch {
// localStorage may be full or disabled, silently ignore
@@ -574,11 +611,16 @@ export class ChatService {
static getStreamState(conversationId: string): ResumableStreamState | null {
if (!conversationId) return null;
try {
const raw = localStorage.getItem(streamStorageKey(conversationId));
if (!raw) return null;
const parsed = JSON.parse(raw) as ResumableStreamState;
if (!parsed || typeof parsed.bytesReceived !== 'number') return null;
return parsed;
} catch {
return null;
@@ -587,6 +629,7 @@ export class ChatService {
static clearStreamState(conversationId: string): void {
if (!conversationId) return;
try {
localStorage.removeItem(streamStorageKey(conversationId));
} catch {
@@ -605,6 +648,7 @@ export class ChatService {
fallbackModel: string | null
): string {
const model = state && state.model !== undefined ? state.model : fallbackModel;
return streamIdentity(conversationId, model);
}
@@ -617,7 +661,9 @@ export class ChatService {
// so issue the GET and abort it right after the status line. 0 on network error
static async probeResumeStatus(streamId: string): Promise<number> {
if (!streamId) return 0;
const ac = new AbortController();
try {
const resp = await fetch(
`${API_STREAM.BASE}?conv_id=${encodeURIComponent(streamId)}&from=0`,
@@ -626,7 +672,9 @@ export class ChatService {
signal: ac.signal
}
);
ac.abort();
return resp.status;
} catch {
return 0;
@@ -639,11 +687,13 @@ export class ChatService {
model?: string | null
): Promise<Response | null> {
if (!conversationId) return null;
const state = ChatService.getStreamState(conversationId);
const from = state?.bytesReceived ?? 0;
const id = streamIdentity(conversationId, model);
const url = `${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}&from=${from}`;
return await fetch(url, { method: 'GET', signal, headers: getAuthHeaders() });
return await fetch(url, { headers: getAuthHeaders(), method: 'GET', signal });
}
static async preEncode(
@@ -673,14 +723,13 @@ export class ChatService {
return true;
});
const requestBody: Record<string, unknown> = {
messages: normalizedMessages.map((msg: ApiChatMessageData) => {
const mapped: Record<string, unknown> = {
role: msg.role,
content: excludeReasoning ? ChatService.stripReasoningContent(msg.content) : msg.content,
tool_calls: msg.tool_calls,
tool_call_id: msg.tool_call_id
role: msg.role,
tool_call_id: msg.tool_call_id,
tool_calls: msg.tool_calls
};
if (!excludeReasoning && msg.reasoning_content) {
@@ -689,8 +738,8 @@ export class ChatService {
return mapped;
}),
stream: false,
n_predict: 0
n_predict: 0,
stream: false
};
if (model) {
@@ -699,9 +748,9 @@ export class ChatService {
try {
await fetch(API_CHAT.COMPLETIONS, {
method: 'POST',
headers: getJsonHeaders(),
body: JSON.stringify(requestBody),
headers: getJsonHeaders(),
method: 'POST',
signal
});
} catch (error) {
@@ -767,10 +816,13 @@ export class ChatService {
// if a resume returns 200 but yields nothing, we abandon
// since the session has a bounded size, the total number of retries is bounded by construction
let madeProgress = true;
const encoder = new TextEncoder();
if (conversationId) {
ChatService.saveStreamState(conversationId, 0, streamModel);
}
onConnectionState?.(StreamConnectionState.STREAMING);
let decoder = new TextDecoder();
@@ -792,7 +844,6 @@ export class ChatService {
toolCallIndexOffset = aggregatedToolCalls.length;
hasOpenToolCallBatch = false;
};
const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
if (!toolCalls || toolCalls.length === 0) {
return;
@@ -824,24 +875,29 @@ export class ChatService {
onToolCallChunk?.(serializedToolCalls);
}
};
const onVisibilityChange = () => {
if (typeof document === 'undefined') return;
if (document.visibilityState !== 'visible') return;
if (streamFinished) return;
if (!conversationId) return;
// the bytes have been quiet for too long, the OS likely killed the socket
// kicking the reader unblocks reader.read with done=true so the outer loop can resume
if (Date.now() - lastByteAt > STREAM_VISIBILITY_KICK_MS) {
reader!.cancel().catch(() => {});
}
};
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibilityChange);
}
try {
let chunk = '';
// outer loop drives the resume cycle, swaps reader on premature end of stream
while (true) {
while (true) {
@@ -849,8 +905,10 @@ export class ChatService {
let done: boolean;
let value: Uint8Array | undefined;
try {
const r = await reader.read();
done = r.done;
value = r.value;
} catch (readErr) {
@@ -860,10 +918,12 @@ export class ChatService {
if (isAbortError(readErr)) {
throw readErr;
}
console.warn('reader.read() rejected, treating as premature end:', readErr);
done = true;
value = undefined;
}
if (done) break;
if (abortSignal?.aborted) break;
@@ -871,6 +931,7 @@ export class ChatService {
if (value && value.byteLength > 0) {
segmentBytesRead += value.byteLength;
lastByteAt = Date.now();
if (!madeProgress) {
madeProgress = true;
onConnectionState?.(StreamConnectionState.STREAMING);
@@ -879,12 +940,14 @@ export class ChatService {
chunk += decoder.decode(value, { stream: true });
const lines = chunk.split(SSE_LINE_SEPARATOR);
chunk = lines.pop() || '';
// the persisted offset must point right after the last fully parsed line,
// the trailing `chunk` is partial bytes still waiting for a newline
if (conversationId) {
const tailBytes = encoder.encode(chunk).byteLength;
bytesParsed = segmentStartOffset + segmentBytesRead - tailBytes;
ChatService.saveStreamState(conversationId, bytesParsed, streamModel);
}
@@ -894,6 +957,7 @@ export class ChatService {
if (line.startsWith(SSE_DATA_PREFIX)) {
const data = line.slice(SSE_DATA_PREFIX.length).trim();
if (data === SSE_DONE_MARKER) {
streamFinished = true;
@@ -908,8 +972,8 @@ export class ChatService {
const toolCalls = choice?.delta?.tool_calls;
const timings = parsed.timings;
const promptProgress = parsed.prompt_progress;
const chunkModel = ChatService.extractModelName(parsed);
if (chunkModel && !modelEmitted) {
modelEmitted = true;
onModel?.(chunkModel);
@@ -932,6 +996,7 @@ export class ChatService {
if (content) {
finalizeOpenToolCallBatch();
aggregatedContent += content;
if (!abortSignal?.aborted) {
onChunk?.(content);
}
@@ -940,6 +1005,7 @@ export class ChatService {
if (reasoningContent) {
finalizeOpenToolCallBatch();
fullReasoningContent += reasoningContent;
if (!abortSignal?.aborted) {
onReasoningChunk?.(reasoningContent);
}
@@ -953,17 +1019,21 @@ export class ChatService {
}
if (abortSignal?.aborted) break;
if (streamFinished) break;
}
// inner reader done, decide whether to try a resume
if (abortSignal?.aborted) break;
if (streamFinished) break;
if (!conversationId) break;
if (!madeProgress) {
onConnectionState?.(StreamConnectionState.LOST);
onError?.(new Error('Stream resume produced no new bytes, giving up'));
break;
}
@@ -978,14 +1048,19 @@ export class ChatService {
abortSignal,
streamModel
).catch(() => null);
// an abort landing during the resume request is intentional, not a lost connection
if (abortSignal?.aborted) break;
if (!resumeResp || resumeResp.status !== 200) {
onConnectionState?.(StreamConnectionState.LOST);
onError?.(new Error('Stream connection lost and could not be resumed'));
break;
}
const newReader = resumeResp.body?.getReader();
if (!newReader) break;
try {
@@ -1030,6 +1105,7 @@ export class ChatService {
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange);
}
try {
reader.releaseLock();
} catch {
@@ -1070,8 +1146,8 @@ export class ChatService {
}
const data: ApiChatCompletionResponse = JSON.parse(responseText);
const responseModel = ChatService.extractModelName(data);
if (responseModel) {
onModel?.(responseModel);
}
@@ -1087,6 +1163,7 @@ export class ChatService {
if (mergedToolCalls.length > 0) {
serializedToolCalls = JSON.stringify(mergedToolCalls);
if (serializedToolCalls) {
onToolCallChunk?.(serializedToolCalls);
}
@@ -1194,14 +1271,15 @@ export class ChatService {
// Handle tool result messages (role: 'tool')
if (message.role === MessageRole.TOOL && message.toolCallId) {
return {
role: MessageRole.TOOL,
content: message.content,
role: MessageRole.TOOL,
tool_call_id: message.toolCallId
};
}
// Parse tool calls for assistant messages
let toolCalls: ApiChatCompletionToolCall[] | undefined;
if (message.toolCalls) {
try {
toolCalls = JSON.parse(message.toolCalls);
@@ -1212,8 +1290,8 @@ export class ChatService {
if (!message.extra || message.extra.length === 0) {
const result: ApiChatMessageData = {
role: message.role as MessageRole,
content: message.content
content: message.content,
role: message.role as MessageRole
};
if (message.reasoningContent) {
@@ -1228,7 +1306,6 @@ export class ChatService {
}
const contentParts: ApiChatMessageContentPart[] = [];
const textFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
extra.type === AttachmentType.TEXT
@@ -1236,8 +1313,8 @@ export class ChatService {
for (const textFile of textFiles) {
contentParts.push({
type: ContentPartType.TEXT,
text: formatAttachmentText('File', textFile.name, textFile.content)
text: formatAttachmentText('File', textFile.name, textFile.content),
type: ContentPartType.TEXT
});
}
@@ -1249,8 +1326,8 @@ export class ChatService {
for (const legacyContextFile of legacyContextFiles) {
contentParts.push({
type: ContentPartType.TEXT,
text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content)
text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content),
type: ContentPartType.TEXT
});
}
@@ -1261,14 +1338,13 @@ export class ChatService {
for (const image of imageFiles) {
const maxImageResolution = settingsStore.getConfig(SETTINGS_KEYS.MAX_IMAGE_RESOLUTION);
// Caps the resolution and bakes the jpeg exif orientation in one pass,
// untouched images pass through as is
const base64Url = await capImageDataURLSize(image.base64Url, maxImageResolution);
contentParts.push({
type: ContentPartType.IMAGE_URL,
image_url: { url: base64Url }
image_url: { url: base64Url },
type: ContentPartType.IMAGE_URL
});
}
@@ -1279,18 +1355,18 @@ export class ChatService {
for (const audio of audioFiles) {
contentParts.push({
type: ContentPartType.INPUT_AUDIO,
input_audio: {
data: audio.base64Data,
format: getAudioInputFormat(audio.mimeType)
}
},
type: ContentPartType.INPUT_AUDIO
});
}
if (message.content) {
contentParts.push({
type: ContentPartType.TEXT,
text: message.content
text: message.content,
type: ContentPartType.TEXT
});
}
@@ -1301,7 +1377,6 @@ export class ChatService {
for (const video of videoFiles) {
contentParts.push({
type: ContentPartType.INPUT_VIDEO,
input_video: {
data: video.base64Data,
format: video.mimeType.includes('mp4')
@@ -1309,7 +1384,8 @@ export class ChatService {
: video.mimeType.includes('ogg')
? 'ogg'
: 'auto'
}
},
type: ContentPartType.INPUT_VIDEO
});
}
@@ -1322,14 +1398,14 @@ export class ChatService {
if (pdfFile.processedAsImages && pdfFile.images) {
for (let i = 0; i < pdfFile.images.length; i++) {
contentParts.push({
type: ContentPartType.IMAGE_URL,
image_url: { url: pdfFile.images[i] }
image_url: { url: pdfFile.images[i] },
type: ContentPartType.IMAGE_URL
});
}
} else {
contentParts.push({
type: ContentPartType.TEXT,
text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content)
text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content),
type: ContentPartType.TEXT
});
}
}
@@ -1341,13 +1417,13 @@ export class ChatService {
for (const mcpPrompt of mcpPrompts) {
contentParts.push({
type: ContentPartType.TEXT,
text: formatAttachmentText(
ATTACHMENT_LABEL_MCP_PROMPT,
mcpPrompt.name,
mcpPrompt.content,
mcpPrompt.serverName
)
),
type: ContentPartType.TEXT
});
}
@@ -1358,26 +1434,29 @@ export class ChatService {
for (const mcpResource of mcpResources) {
contentParts.push({
type: ContentPartType.TEXT,
text: formatAttachmentText(
ATTACHMENT_LABEL_MCP_RESOURCE,
mcpResource.name,
mcpResource.content,
mcpResource.serverName
)
),
type: ContentPartType.TEXT
});
}
const result: ApiChatMessageData = {
role: message.role as MessageRole,
content: contentParts
content: contentParts,
role: message.role as MessageRole
};
if (message.reasoningContent) {
result.reasoning_content = message.reasoningContent;
}
if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls;
}
return result;
}
@@ -1407,6 +1486,7 @@ export class ChatService {
if (part.type === ContentPartType.TEXT && part.text) {
return { ...part, text: stripFromString(part.text) };
}
return part;
});
}
@@ -1422,17 +1502,17 @@ export class ChatService {
try {
const errorText = await response.text();
const errorData: ApiErrorResponse = JSON.parse(errorText);
const message = errorData.error?.message || 'Unknown server error';
const error = new Error(message) as Error & {
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
};
error.name = response.status === 400 ? 'ServerError' : 'HttpError';
if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) {
error.contextInfo = {
n_prompt_tokens: errorData.error.n_prompt_tokens,
n_ctx: errorData.error.n_ctx
n_ctx: errorData.error.n_ctx,
n_prompt_tokens: errorData.error.n_prompt_tokens
};
}
@@ -1443,6 +1523,7 @@ export class ChatService {
) as Error & {
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
};
fallback.name = 'HttpError';
return fallback;
@@ -1466,33 +1547,36 @@ export class ChatService {
? (value as Record<string, unknown>)
: undefined;
};
const getTrimmedString = (value: unknown): string | undefined => {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const root = asRecord(data);
if (!root) return undefined;
// 1) root (some implementations provide `model` at the top level)
const rootModel = getTrimmedString(root.model);
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;
}
// priority: delta.model (first chunk) else message.model (final response)
const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
if (deltaModel) {
return deltaModel;
}
const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
if (messageModel) {
return messageModel;
}
+84 -37
View File
@@ -1,9 +1,9 @@
import Dexie, { type EntityTable } from 'dexie';
import { findDescendantMessages, uuid, filterByLeafNodeId } from '$lib/utils';
import { IDXDB_TABLES, IDXDB_STORES, STORAGE_APP_NAME } from '$lib/constants';
import { IDXDB_STORES, IDXDB_TABLES, STORAGE_APP_NAME } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
import type { ExportedConversation } from '$lib/types/database';
import { filterByLeafNodeId, findDescendantMessages, uuid } from '$lib/utils';
import Dexie, { type EntityTable } from 'dexie';
class LlamaUiDatabase extends Dexie {
[IDXDB_TABLES.conversations]!: EntityTable<DatabaseConversation, string>;
@@ -39,14 +39,15 @@ export class DatabaseService {
fields?: Partial<Omit<DatabaseConversation, 'id' | 'name' | 'lastModified'>>
): Promise<DatabaseConversation> {
const conversation: DatabaseConversation = {
id: uuid(),
name,
lastModified: Date.now(),
currNode: '',
id: uuid(),
lastModified: Date.now(),
name,
...fields
};
await db[IDXDB_TABLES.conversations].add(conversation);
return conversation;
}
@@ -77,6 +78,7 @@ export class DatabaseService {
// Handle null parent (root message case)
if (parentId !== null) {
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
if (!parentMessage) {
throw new Error(`Parent message ${parentId} not found`);
}
@@ -84,10 +86,10 @@ export class DatabaseService {
const newMessage: DatabaseMessage = {
...message,
children: [],
id: uuid(),
parent: parentId,
toolCalls: message.toolCalls ?? '',
children: []
toolCalls: message.toolCalls ?? ''
};
await db[IDXDB_TABLES.messages].add(newMessage);
@@ -95,6 +97,7 @@ export class DatabaseService {
// Update parent's children array if parent exists
if (parentId !== null) {
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
if (parentMessage) {
await db[IDXDB_TABLES.messages].update(parentId, {
children: [...parentMessage.children, newMessage.id]
@@ -120,18 +123,19 @@ export class DatabaseService {
*/
static async createRootMessage(convId: string): Promise<string> {
const rootMessage: DatabaseMessage = {
id: uuid(),
convId,
type: 'root',
timestamp: Date.now(),
role: MessageRole.SYSTEM,
children: [],
content: '',
convId,
id: uuid(),
parent: null,
role: MessageRole.SYSTEM,
timestamp: Date.now(),
toolCalls: '',
children: []
type: 'root'
};
await db[IDXDB_TABLES.messages].add(rootMessage);
return rootMessage.id;
}
@@ -150,25 +154,27 @@ export class DatabaseService {
parentId: string
): Promise<DatabaseMessage> {
const trimmedPrompt = systemPrompt.trim();
if (!trimmedPrompt) {
throw new Error('Cannot create system message with empty content');
}
return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
if (!parentMessage) {
throw new Error(`Parent message ${parentId} not found`);
}
const systemMessage: DatabaseMessage = {
id: uuid(),
convId,
type: MessageRole.SYSTEM,
timestamp: Date.now(),
role: MessageRole.SYSTEM,
children: [],
content: trimmedPrompt,
convId,
id: uuid(),
parent: parentId,
children: []
role: MessageRole.SYSTEM,
timestamp: Date.now(),
type: MessageRole.SYSTEM
};
await db[IDXDB_TABLES.messages].add(systemMessage);
@@ -240,35 +246,46 @@ export class DatabaseService {
prefetched?: ReadonlyMap<string, DatabaseConversation>
): Promise<void> {
const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
if (!conv) return;
let newParent = conv.forkedFromConversationId;
const visited = new Set<string>([parentId]);
while (newParent && excludeIds.has(newParent)) {
if (visited.has(newParent)) {
newParent = undefined;
break;
}
visited.add(newParent);
const next =
prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
if (!next) {
newParent = undefined;
break;
}
newParent = next.forkedFromConversationId;
}
const directChildren = await db[IDXDB_TABLES.conversations]
.filter((c) => c.forkedFromConversationId === parentId)
.toArray();
const updates: DatabaseConversation[] = [];
for (const child of directChildren) {
if (excludeIds.has(child.id)) continue;
updates.push({ ...child, forkedFromConversationId: newParent });
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
}
@@ -282,7 +299,9 @@ export class DatabaseService {
*/
static async bulkDeleteConversations(ids: string[]): Promise<void> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return;
const idSet = new Set(cleanIds);
await db.transaction(
@@ -292,16 +311,23 @@ export class DatabaseService {
// Pre-load each to-delete conversation so the per-id reparent
// walk-up doesn't ping-pong the same ancestry chain.
const prefetched = new Map<string, DatabaseConversation>();
let frontier = [...cleanIds];
const requested = new Set<string>(frontier);
while (frontier.length > 0) {
const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
frontier = [];
for (let i = 0; i < fetched.length; i++) {
const conv = fetched[i];
if (!conv || !conv.id) continue;
prefetched.set(conv.id, conv);
const ancestor = conv.forkedFromConversationId;
if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
frontier.push(ancestor);
requested.add(ancestor);
@@ -327,11 +353,13 @@ export class DatabaseService {
static async deleteMessage(messageId: string): Promise<void> {
await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
const message = await db[IDXDB_TABLES.messages].get(messageId);
if (!message) return;
// Remove this message from its parent's children array
if (message.parent) {
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
@@ -361,15 +389,15 @@ export class DatabaseService {
.where('convId')
.equals(conversationId)
.toArray();
// Find all descendant messages
const descendants = findDescendantMessages(allMessages, messageId);
const allToDelete = [messageId, ...descendants];
// Get the message to delete for parent cleanup
const message = await db[IDXDB_TABLES.messages].get(messageId);
if (message && message.parent) {
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
@@ -424,28 +452,34 @@ export class DatabaseService {
): Promise<Map<string, ExportedConversation>> {
const result = new Map<string, ExportedConversation>();
const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return result;
const [convs, allMessages] = await Promise.all([
db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
]);
const messagesByConv = new Map<string, DatabaseMessage[]>();
for (const msg of allMessages) {
const bucket = messagesByConv.get(msg.convId);
if (bucket) bucket.push(msg);
else messagesByConv.set(msg.convId, [msg]);
}
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const messages = (messagesByConv.get(conv.id) ?? []).sort(
(a, b) => a.timestamp - b.timestamp
);
result.set(conv.id, { conv, messages });
}
return result;
}
@@ -480,11 +514,15 @@ export class DatabaseService {
*/
static async toggleConversationPin(id: string): Promise<boolean> {
const conversation = await db[IDXDB_TABLES.conversations].get(id);
if (!conversation) {
throw new Error(`Conversation ${id} not found`);
}
const newPinnedState = !conversation.pinned;
await this.updateConversation(id, { pinned: newPinnedState });
return newPinnedState;
}
@@ -501,21 +539,29 @@ export class DatabaseService {
static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
const result = new Map<string, boolean>();
if (cleanIds.length === 0) return result;
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
const updates: DatabaseConversation[] = [];
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const newPinned = !conv.pinned;
updates.push({ ...conv, pinned: newPinned });
result.set(cleanIds[i], newPinned);
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
});
return result;
}
@@ -573,10 +619,11 @@ export class DatabaseService {
async () => {
for (const item of data) {
const { conv, messages } = item;
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
if (existing) {
skipped.push(conv);
continue;
}
@@ -620,6 +667,7 @@ export class DatabaseService {
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
const sourceConv = await db[IDXDB_TABLES.conversations].get(sourceConvId);
if (!sourceConv) {
throw new Error(`Source conversation ${sourceConvId} not found`);
}
@@ -628,12 +676,12 @@ export class DatabaseService {
.where('convId')
.equals(sourceConvId)
.toArray();
const pathMessages = filterByLeafNodeId(
allMessages,
atMessageId,
true
) as DatabaseMessage[];
if (pathMessages.length === 0) {
throw new Error(`Could not resolve message path to ${atMessageId}`);
}
@@ -654,28 +702,27 @@ export class DatabaseService {
return {
...msg,
id: newId,
convId: newConvId,
parent: newParent,
children: newChildren,
extra: options.includeAttachments ? msg.extra : undefined
convId: newConvId,
extra: options.includeAttachments ? msg.extra : undefined,
id: newId,
parent: newParent
};
});
const lastClonedMessage = clonedMessages[clonedMessages.length - 1];
const newConv: DatabaseConversation = {
id: newConvId,
name: options.name,
lastModified: Date.now(),
currNode: lastClonedMessage.id,
cwd: sourceConv.cwd,
forkedFromConversationId: sourceConvId,
id: newConvId,
lastModified: Date.now(),
mcpServerOverrides: sourceConv.mcpServerOverrides
? sourceConv.mcpServerOverrides.map((o: McpServerOverride) => ({
serverId: o.serverId,
enabled: o.enabled
enabled: o.enabled,
serverId: o.serverId
}))
: undefined,
cwd: sourceConv.cwd
name: options.name
};
await db[IDXDB_TABLES.conversations].add(newConv);
+135 -121
View File
@@ -1,63 +1,63 @@
import { Client } from '@modelcontextprotocol/sdk/client';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import {
StreamableHTTPClientTransport,
StreamableHTTPError
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import type {
Tool,
Prompt,
GetPromptResult,
ListChangedHandlers
} from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
GetPromptResult,
ListChangedHandlers,
Prompt,
Tool
} from '@modelcontextprotocol/sdk/types.js';
import {
DEFAULT_MCP_CONFIG,
CORS_PROXY_ENDPOINT,
CORS_PROXY_HEADER_PREFIX,
DEFAULT_CLIENT_VERSION,
DEFAULT_IMAGE_MIME_TYPE,
CORS_PROXY_HEADER_PREFIX,
MCP_PARTIAL_REDACT_HEADERS,
CORS_PROXY_ENDPOINT
DEFAULT_MCP_CONFIG,
MCP_PARTIAL_REDACT_HEADERS
} from '$lib/constants';
import {
MCPConnectionPhase,
MCPLogLevel,
MCPTransportType,
MCPContentType,
MCPRefType
MCPLogLevel,
MCPRefType,
MCPTransportType
} from '$lib/enums';
import type {
MCPServerConfig,
MCPResourceIcon,
ToolCallParams,
ToolExecutionResult,
Implementation,
ClientCapabilities,
Implementation,
MCPConnection,
MCPPhaseCallback,
MCPConnectionLog,
MCPServerInfo,
MCPPhaseCallback,
MCPReadResourceResult,
MCPResource,
MCPResourceTemplate,
MCPResourceContent,
MCPReadResourceResult
MCPResourceIcon,
MCPResourceTemplate,
MCPServerConfig,
MCPServerInfo,
ToolCallParams,
ToolExecutionResult
} from '$lib/types';
import {
buildProxiedUrl,
buildProxiedHeaders,
getAuthHeaders,
sanitizeHeaders,
throwIfAborted,
isAbortError,
buildProxiedUrl,
createBase64DataUrl,
getRequestUrl,
getRequestMethod,
getRequestBody,
summarizeRequestBody,
formatDiagnosticErrorMessage,
extractJsonRpcMethods,
type RequestBodySummary
formatDiagnosticErrorMessage,
getAuthHeaders,
getRequestBody,
getRequestMethod,
getRequestUrl,
isAbortError,
type RequestBodySummary,
sanitizeHeaders,
summarizeRequestBody,
throwIfAborted
} from '$lib/utils';
interface ToolResultContentItem {
@@ -101,11 +101,11 @@ export class MCPService {
details?: unknown
): MCPConnectionLog {
return {
timestamp: new Date(),
phase,
message,
details,
level,
details
message,
phase,
timestamp: new Date()
};
}
@@ -118,12 +118,12 @@ export class MCPService {
): DiagnosticRequestDetails {
const body = getRequestBody(input, init);
const details: DiagnosticRequestDetails = {
url: getRequestUrl(input),
method: getRequestMethod(input, init, baseInit).toUpperCase(),
body: summarizeRequestBody(body),
credentials: init?.credentials ?? baseInit.credentials,
mode: init?.mode ?? baseInit.mode,
headers: sanitizeHeaders(requestHeaders, extraRedactedHeaders, MCP_PARTIAL_REDACT_HEADERS),
body: summarizeRequestBody(body)
method: getRequestMethod(input, init, baseInit).toUpperCase(),
mode: init?.mode ?? baseInit.mode,
url: getRequestUrl(input)
};
const jsonRpcMethods = extractJsonRpcMethods(body);
@@ -144,6 +144,7 @@ export class MCPService {
useProxy && !key.toLowerCase().startsWith(CORS_PROXY_HEADER_PREFIX)
? `${CORS_PROXY_HEADER_PREFIX}${key}`
: key;
requestHeaders.set(proxiedKey, value);
}
}
@@ -151,12 +152,12 @@ export class MCPService {
private static summarizeError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
cause:
error.cause instanceof Error
? { name: error.cause.name, message: error.cause.message }
? { message: error.cause.message, name: error.cause.name }
: error.cause,
message: error.message,
name: error.name,
stack: error.stack?.split('\n').slice(0, 6).join('\n')
};
}
@@ -173,13 +174,13 @@ export class MCPService {
}
return {
isSecureContext: window.isSecureContext,
location: window.location.href,
origin: window.location.origin,
protocol: window.location.protocol,
isSecureContext: window.isSecureContext,
sameOrigin: window.location.origin === targetUrl.origin,
targetOrigin: targetUrl.origin,
targetProtocol: targetUrl.protocol,
sameOrigin: window.location.origin === targetUrl.origin,
useProxy
};
}
@@ -244,6 +245,7 @@ export class MCPService {
disable: () => void;
} {
let enabled = true;
const logIfEnabled = (log: MCPConnectionLog) => {
if (enabled) {
onLog?.(log);
@@ -251,9 +253,13 @@ export class MCPService {
};
return {
disable: () => {
enabled = false;
},
fetch: async (input, init) => {
if (useProxy && typeof window !== 'undefined') {
let requestUrlStr = '';
if (typeof input === 'string') {
requestUrlStr = input;
} else if (input instanceof URL) {
@@ -262,6 +268,7 @@ export class MCPService {
if (requestUrlStr) {
const parsedRequestUrl = new URL(requestUrlStr, window.location.origin);
if (
parsedRequestUrl.origin === window.location.origin &&
!parsedRequestUrl.pathname.includes(CORS_PROXY_ENDPOINT)
@@ -308,8 +315,8 @@ export class MCPService {
`HTTP ${method} ${url}`,
MCPLogLevel.INFO,
{
serverName,
request
request,
serverName
}
)
);
@@ -324,11 +331,11 @@ export class MCPService {
MCPLogLevel.INFO,
{
response: {
url,
durationMs: 0,
isFake: true,
status: response.status,
statusText: response.statusText,
durationMs: 0,
isFake: true
url
}
}
)
@@ -353,11 +360,11 @@ export class MCPService {
response.ok ? MCPLogLevel.INFO : MCPLogLevel.WARN,
{
response: {
url,
durationMs,
headers: sanitizeHeaders(response.headers, undefined, MCP_PARTIAL_REDACT_HEADERS),
status: response.status,
statusText: response.statusText,
headers: sanitizeHeaders(response.headers, undefined, MCP_PARTIAL_REDACT_HEADERS),
durationMs
url
}
}
)
@@ -373,21 +380,18 @@ export class MCPService {
`HTTP ${method} ${url} failed: ${formatDiagnosticErrorMessage(error)}`,
MCPLogLevel.ERROR,
{
serverName,
request,
error: this.summarizeError(error),
browser: this.getBrowserContext(targetUrl, useProxy),
durationMs,
error: this.summarizeError(error),
hints: this.getConnectionHints(targetUrl, config, error),
durationMs
request,
serverName
}
)
);
throw error;
}
},
disable: () => {
enabled = false;
}
};
}
@@ -463,15 +467,15 @@ export class MCPService {
}
return {
stopPhaseLogging: () => {},
transport: new WebSocketClientTransport(url),
type: MCPTransportType.WEBSOCKET,
stopPhaseLogging: () => {}
type: MCPTransportType.WEBSOCKET
};
}
if (config.transport === MCPTransportType.SSE) {
const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url);
const { fetch: diagnosticFetch, disable: stopPhaseLogging } = this.createDiagnosticFetch(
const { disable: stopPhaseLogging, fetch: diagnosticFetch } = this.createDiagnosticFetch(
serverName,
config,
requestInit,
@@ -485,18 +489,18 @@ export class MCPService {
}
return {
stopPhaseLogging,
transport: new SSEClientTransport(url, {
requestInit,
eventSourceInit: { fetch: diagnosticFetch },
fetch: diagnosticFetch,
eventSourceInit: { fetch: diagnosticFetch }
requestInit
}),
type: MCPTransportType.SSE,
stopPhaseLogging
type: MCPTransportType.SSE
};
}
const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url);
const { fetch: diagnosticFetch, disable: stopPhaseLogging } = this.createDiagnosticFetch(
const { disable: stopPhaseLogging, fetch: diagnosticFetch } = this.createDiagnosticFetch(
serverName,
config,
requestInit,
@@ -515,25 +519,25 @@ export class MCPService {
}
return {
stopPhaseLogging,
transport: new StreamableHTTPClientTransport(url, {
requestInit,
fetch: diagnosticFetch
fetch: diagnosticFetch,
requestInit
}),
type: MCPTransportType.STREAMABLE_HTTP,
stopPhaseLogging
type: MCPTransportType.STREAMABLE_HTTP
};
} catch (httpError) {
console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError);
try {
return {
stopPhaseLogging,
transport: new SSEClientTransport(url, {
requestInit,
eventSourceInit: { fetch: diagnosticFetch },
fetch: diagnosticFetch,
eventSourceInit: { fetch: diagnosticFetch }
requestInit
}),
type: MCPTransportType.SSE,
stopPhaseLogging
type: MCPTransportType.SSE
};
} catch (sseError) {
const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
@@ -557,17 +561,17 @@ export class MCPService {
}
return {
name: impl.name,
version: impl.version,
title: impl.title,
description: impl.description,
websiteUrl: impl.websiteUrl,
icons: impl.icons?.map((icon: MCPResourceIcon) => ({
src: icon.src,
mimeType: icon.mimeType,
sizes: icon.sizes,
src: icon.src,
theme: icon.theme
}))
})),
name: impl.name,
title: impl.title,
version: impl.version,
websiteUrl: impl.websiteUrl
};
}
@@ -617,9 +621,9 @@ export class MCPService {
}
const {
stopPhaseLogging,
transport,
type: transportType,
stopPhaseLogging
type: transportType
} = this.createTransport(serverName, serverConfig, (log) => onPhase?.(log.phase, log));
// Setup WebSocket reconnection handler
@@ -650,7 +654,6 @@ export class MCPService {
listChanged: listChangedHandlers
}
);
const runtimeErrorHandler = (error: Error) => {
// the SDK reports any post initialize error here, including the abort we trigger
// ourselves on the next health check cycle, on tab unload, or on server teardown.
@@ -661,7 +664,9 @@ export class MCPService {
if (isAbortError(error)) {
return;
}
const msg = error?.message ?? '';
if (
/SSE stream disconnected:.*AbortError/i.test(msg) ||
/AbortError: .*aborted/i.test(msg) ||
@@ -669,6 +674,7 @@ export class MCPService {
) {
return;
}
console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
};
@@ -701,6 +707,7 @@ export class MCPService {
try {
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
const handshakeDeadline = new Promise<never>((_, reject) => {
handshakeTimer = setTimeout(() => {
void transport.close().catch(() => {});
@@ -736,21 +743,21 @@ export class MCPService {
}`,
MCPLogLevel.ERROR,
{
error: this.summarizeError(error),
browser: this.getBrowserContext(url, serverConfig.useProxy ?? false),
config: {
serverName,
configuredUrl: serverConfig.url,
credentials: serverConfig.credentials,
effectiveUrl: url.href,
transportType,
useProxy: serverConfig.useProxy ?? false,
headers: sanitizeHeaders(
serverConfig.headers,
Object.keys(serverConfig.headers ?? {}),
MCP_PARTIAL_REDACT_HEADERS
),
credentials: serverConfig.credentials
serverName,
transportType,
useProxy: serverConfig.useProxy ?? false
},
browser: this.getBrowserContext(url, serverConfig.useProxy ?? false),
error: this.summarizeError(error),
hints: this.getConnectionHints(url, serverConfig, error)
}
)
@@ -777,10 +784,10 @@ export class MCPService {
}
),
{
serverInfo,
serverCapabilities,
clientCapabilities: effectiveCapabilities,
instructions
instructions,
serverCapabilities,
serverInfo
}
);
@@ -796,15 +803,14 @@ export class MCPService {
const tools = await this.listTools({
client,
transport,
tools: [],
serverName,
transportType,
connectionTimeMs: 0,
requestTimeoutMs:
serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000
serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000,
serverName,
tools: [],
transport,
transportType
});
const connectionTimeMs = Math.round(performance.now() - startTime);
// Phase: Connected
@@ -815,6 +821,7 @@ export class MCPService {
`Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
)
);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
@@ -823,18 +830,18 @@ export class MCPService {
return {
client,
transport,
tools,
serverName,
transportType,
serverInfo,
serverCapabilities,
clientCapabilities: effectiveCapabilities,
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
instructions,
connectionTimeMs,
instructions,
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
requestTimeoutMs:
serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000
serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000,
serverCapabilities,
serverInfo,
serverName,
tools,
transport,
transportType
};
}
@@ -861,6 +868,7 @@ export class MCPService {
// by not setting onerror, but since we use it for protocol logging,
// we must clear it before disconnect.
connection.client.onerror = undefined;
if (connection.transport.onclose) {
connection.transport.onclose = undefined;
}
@@ -936,7 +944,7 @@ export class MCPService {
args?: Record<string, string>
): Promise<GetPromptResult> {
try {
return await connection.client.getPrompt({ name, arguments: args });
return await connection.client.getPrompt({ arguments: args, name });
} catch (error) {
console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error);
@@ -964,7 +972,7 @@ export class MCPService {
try {
const result = await connection.client.callTool(
{ name: params.name, arguments: params.arguments },
{ arguments: params.arguments, name: params.name },
undefined,
{ signal, timeout: connection.requestTimeoutMs }
);
@@ -1001,6 +1009,7 @@ export class MCPService {
*/
private static formatToolResult(result: ToolCallResult): string {
const content = result.content;
if (!Array.isArray(content)) return '';
return content
@@ -1022,6 +1031,7 @@ export class MCPService {
const resource = content.resource;
if (resource.text) return resource.text;
if (resource.blob) return resource.blob;
return JSON.stringify(resource);
@@ -1058,8 +1068,8 @@ export class MCPService {
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
try {
const result = await connection.client.complete({
ref,
argument
argument,
ref
});
return result.completion;
@@ -1092,8 +1102,8 @@ export class MCPService {
const result = await connection.client.listResources(cursor ? { cursor } : undefined);
return {
resources: (result.resources ?? []) as MCPResource[],
nextCursor: result.nextCursor
nextCursor: result.nextCursor,
resources: (result.resources ?? []) as MCPResource[]
};
} catch (error) {
if (this.isSessionExpiredError(error)) {
@@ -1113,10 +1123,12 @@ export class MCPService {
*/
static async listAllResources(connection: MCPConnection): Promise<MCPResource[]> {
const allResources: MCPResource[] = [];
let cursor: string | undefined;
do {
const result = await this.listResources(connection, cursor);
allResources.push(...result.resources);
cursor = result.nextCursor;
} while (cursor);
@@ -1138,8 +1150,8 @@ export class MCPService {
const result = await connection.client.listResourceTemplates(cursor ? { cursor } : undefined);
return {
resourceTemplates: (result.resourceTemplates ?? []) as MCPResourceTemplate[],
nextCursor: result.nextCursor
nextCursor: result.nextCursor,
resourceTemplates: (result.resourceTemplates ?? []) as MCPResourceTemplate[]
};
} catch (error) {
if (this.isSessionExpiredError(error)) {
@@ -1162,10 +1174,12 @@ export class MCPService {
*/
static async listAllResourceTemplates(connection: MCPConnection): Promise<MCPResourceTemplate[]> {
const allTemplates: MCPResourceTemplate[] = [];
let cursor: string | undefined;
do {
const result = await this.listResourceTemplates(connection, cursor);
allTemplates.push(...result.resourceTemplates);
cursor = result.nextCursor;
} while (cursor);
@@ -1187,8 +1201,8 @@ export class MCPService {
const result = await connection.client.readResource({ uri });
return {
contents: (result.contents ?? []) as MCPResourceContent[],
_meta: result._meta
_meta: result._meta,
contents: (result.contents ?? []) as MCPResourceContent[]
};
} catch (error) {
console.error(`[MCPService][${connection.serverName}] Failed to read resource:`, error);
+115 -59
View File
@@ -17,19 +17,19 @@
* 4. Theme key: Copy standalone `theme` → config object (both preserved)
*/
import Dexie from 'dexie';
import {
STORAGE_APP_NAME,
STORAGE_APP_NAME_DEPRECATED,
DB_APP_NAME_DEPRECATED,
CONFIG_LOCALSTORAGE_KEY,
IDXDB_TABLES,
DB_APP_NAME_DEPRECATED,
IDXDB_STORES,
NEW_TO_DEPRECATED_MAP
IDXDB_TABLES,
NEW_TO_DEPRECATED_MAP,
STORAGE_APP_NAME,
STORAGE_APP_NAME_DEPRECATED
} from '$lib/constants';
import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants/agentic';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { MessageRole } from '$lib/enums';
import Dexie from 'dexie';
// Types
@@ -58,11 +58,15 @@ const MIGRATION_STATE_VERSION = 1;
function getMigrationState(): MigrationState {
try {
const raw = localStorage.getItem(MIGRATION_STATE_KEY);
if (!raw) return { completed: [], failed: [], lastRun: '' };
const parsed = JSON.parse(raw);
if (parsed.version !== MIGRATION_STATE_VERSION) {
return { completed: [], failed: [], lastRun: '' };
}
return {
completed: parsed.completed ?? [],
failed: parsed.failed ?? [],
@@ -86,48 +90,56 @@ function saveMigrationState(state: MigrationState): void {
function isMigrationCompleted(id: string): boolean {
const state = getMigrationState();
return state.completed.includes(id);
}
function markMigrationCompleted(id: string): void {
const state = getMigrationState();
if (!state.completed.includes(id)) {
state.completed.push(id);
}
state.failed = state.failed.filter((f) => f !== id);
saveMigrationState(state);
}
function markMigrationFailed(id: string): void {
const state = getMigrationState();
if (!state.failed.includes(id)) {
state.failed.push(id);
}
saveMigrationState(state);
}
// Migration 1: LocalStorage Key Prefix (Non-Destructive)
const LOCALSTORAGE_MIGRATION_ID = 'localstorage-prefix-v1';
const localStorageMigration: Migration = {
id: LOCALSTORAGE_MIGRATION_ID,
description: 'Copy localStorage keys from LlamaCppWebui to LlamaUi prefix (non-destructive)',
id: LOCALSTORAGE_MIGRATION_ID,
async run(): Promise<void> {
// Non-destructive: copy to new key, but KEEP the old key
for (const [newKey, deprecatedKey] of Object.entries(NEW_TO_DEPRECATED_MAP)) {
// Only migrate if new key doesn't already exist
const newValue = localStorage.getItem(newKey);
if (newValue !== null) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
continue;
}
const oldValue = localStorage.getItem(deprecatedKey);
if (oldValue !== null) {
localStorage.setItem(newKey, oldValue);
// Keep old key for downgrade compatibility - DO NOT DELETE
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(
@@ -141,27 +153,32 @@ const localStorageMigration: Migration = {
// Migration 2: IndexedDB Database Name (Non-Destructive)
// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
const IDXDB_MIGRATION_ID = 'idxdb-database-v1';
const idxdbMigration: Migration = {
id: IDXDB_MIGRATION_ID,
description: 'Copy IndexedDB from LlamacppWebui to LlamaUi database (non-destructive)',
id: IDXDB_MIGRATION_ID,
async run(): Promise<void> {
const oldDbNames = await Dexie.getDatabaseNames();
if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] IndexedDB: no old database found, skipping');
return;
}
// Check if new database already has data
const newDb = new Dexie(STORAGE_APP_NAME);
newDb.version(1).stores(IDXDB_STORES);
const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
if (existingConvs > 0) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] IndexedDB: new database already has data, skipping');
return;
}
@@ -169,6 +186,7 @@ const idxdbMigration: Migration = {
console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
oldDb.version(1).stores(IDXDB_STORES);
const conversations = await oldDb.table(IDXDB_TABLES.conversations).toArray();
@@ -176,11 +194,14 @@ const idxdbMigration: Migration = {
if (conversations.length > 0) {
await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
}
if (messages.length > 0) {
await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
}
@@ -193,6 +214,7 @@ const idxdbMigration: Migration = {
// Migration 3: Legacy Message Format
// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
const LEGACY_MESSAGE_MIGRATION_ID = 'legacy-message-format-v2';
interface ParsedTurn {
@@ -219,8 +241,8 @@ function parseLegacyToolCalls(content: string): ParsedTurn[] {
}
currentTurn.toolCalls.push({
name: match[1],
args: match[2],
name: match[1],
result: match[3].replace(/^\n+|\n+$/g, '')
});
@@ -237,6 +259,7 @@ function parseLegacyToolCalls(content: string): ParsedTurn[] {
const cleanRemaining = remainingText
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
.trim();
if (cleanRemaining) {
turns.push({ textBefore: cleanRemaining, toolCalls: [] });
}
@@ -254,7 +277,9 @@ function extractLegacyReasoning(content: string): { reasoning: string; cleanCont
let cleanContent = content;
const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
let match;
while ((match = re.exec(content)) !== null) {
reasoning += match[1];
}
@@ -263,7 +288,7 @@ function extractLegacyReasoning(content: string): { reasoning: string; cleanCont
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
return { reasoning, cleanContent };
return { cleanContent, reasoning };
}
function hasLegacyMarkers(content: string): boolean {
@@ -275,18 +300,21 @@ let DatabaseService: typeof import('./database.service').DatabaseService | null
async function getDatabaseService() {
if (!DatabaseService) {
const module = await import('./database.service');
DatabaseService = module.DatabaseService;
}
return DatabaseService;
}
const legacyMessageMigration: Migration = {
id: LEGACY_MESSAGE_MIGRATION_ID,
description: 'Migrate legacy marker-based messages to structured format',
id: LEGACY_MESSAGE_MIGRATION_ID,
async run(): Promise<void> {
const db = await getDatabaseService();
const conversations = await db.getAllConversations();
let migratedCount = 0;
for (const conv of conversations) {
@@ -295,25 +323,28 @@ const legacyMessageMigration: Migration = {
for (const message of allMessages) {
if (message.role !== MessageRole.ASSISTANT) {
if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
const { cleanContent, reasoning } = extractLegacyReasoning(message.content);
await db.updateMessage(message.id, {
content: cleanContent.trim(),
reasoningContent: reasoning || undefined
});
migratedCount++;
}
continue;
}
if (!hasLegacyMarkers(message.content ?? '')) continue;
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
const { cleanContent, reasoning } = extractLegacyReasoning(message.content);
const turns = parseLegacyToolCalls(cleanContent);
let existingToolCalls: Array<{
id: string;
function?: { name: string; arguments: string };
}> = [];
if (message.toolCalls) {
try {
existingToolCalls = JSON.parse(message.toolCalls);
@@ -323,15 +354,17 @@ const legacyMessageMigration: Migration = {
}
const firstTurn = turns[0];
if (!firstTurn) continue;
const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
const existing =
existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
return {
function: { arguments: tc.args, name: tc.name },
id: existing?.id || `legacy_tool_${i}`,
type: 'function' as const,
function: { name: tc.name, arguments: tc.args }
type: 'function' as const
};
});
@@ -347,69 +380,71 @@ const legacyMessageMigration: Migration = {
for (let i = 0; i < firstTurn.toolCalls.length; i++) {
const tc = firstTurn.toolCalls[i];
const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`;
const toolMsg = await db.createMessageBranch(
{
convId: conv.id,
type: 'text',
role: MessageRole.TOOL,
children: [],
content: tc.result,
toolCallId,
convId: conv.id,
role: MessageRole.TOOL,
timestamp: message.timestamp + i + 1,
toolCallId,
toolCalls: '',
children: []
type: 'text'
},
currentParentId
);
currentParentId = toolMsg.id;
}
for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) {
const turn = turns[turnIdx];
const turnToolCalls = turn.toolCalls.map((tc, i) => {
const idx = toolCallIdCounter + i;
const existing = existingToolCalls[idx];
return {
function: { arguments: tc.args, name: tc.name },
id: existing?.id || `legacy_tool_${idx}`,
type: 'function' as const,
function: { name: tc.name, arguments: tc.args }
type: 'function' as const
};
});
toolCallIdCounter += turn.toolCalls.length;
const assistantMsg = await db.createMessageBranch(
{
convId: conv.id,
type: 'text',
role: MessageRole.ASSISTANT,
children: [],
content: turn.textBefore,
convId: conv.id,
model: message.model,
role: MessageRole.ASSISTANT,
timestamp: message.timestamp + turnIdx * 100,
toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
children: [],
model: message.model
type: 'text'
},
currentParentId
);
currentParentId = assistantMsg.id;
for (let i = 0; i < turn.toolCalls.length; i++) {
const tc = turn.toolCalls[i];
const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`;
const toolMsg = await db.createMessageBranch(
{
convId: conv.id,
type: 'text',
role: MessageRole.TOOL,
children: [],
content: tc.result,
toolCallId,
convId: conv.id,
role: MessageRole.TOOL,
timestamp: message.timestamp + turnIdx * 100 + i + 1,
toolCallId,
toolCalls: '',
children: []
type: 'text'
},
currentParentId
);
currentParentId = toolMsg.id;
}
}
@@ -417,7 +452,9 @@ const legacyMessageMigration: Migration = {
if (message.children.length > 0 && currentParentId !== message.id) {
for (const childId of message.children) {
const child = allMessages.find((m) => m.id === childId);
if (!child) continue;
if (child.role !== MessageRole.TOOL) {
await db.updateMessage(childId, { parent: currentParentId });
}
@@ -436,17 +473,19 @@ const legacyMessageMigration: Migration = {
// Migration 4: Theme Key (Non-Destructive)
// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
const THEME_MIGRATION_ID = 'theme-key-v1';
const themeMigration: Migration = {
id: THEME_MIGRATION_ID,
description: 'Copy standalone theme key to config object (non-destructive)',
id: THEME_MIGRATION_ID,
async run(): Promise<void> {
const legacyTheme = localStorage.getItem('theme');
if (legacyTheme === null) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Theme: no legacy theme key found, skipping');
return;
}
@@ -457,6 +496,7 @@ const themeMigration: Migration = {
if (SETTINGS_KEYS.THEME in config) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Theme: config already has theme, skipping');
return;
}
@@ -471,19 +511,21 @@ const themeMigration: Migration = {
// Migration Registry & Runner
// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
const CUSTOM_JSON_MIGRATION_ID = 'custom-json-key-v1';
const customJsonKeyMigration: Migration = {
id: CUSTOM_JSON_MIGRATION_ID,
description: 'Copy legacy custom config key to customJson (non-destructive)',
id: CUSTOM_JSON_MIGRATION_ID,
async run(): Promise<void> {
const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
if (configRaw === null) return;
const config = JSON.parse(configRaw);
if (!('custom' in config)) return;
if (SETTINGS_KEYS.CUSTOM_JSON in config) return;
config[SETTINGS_KEYS.CUSTOM_JSON] = config.custom;
@@ -494,16 +536,13 @@ const customJsonKeyMigration: Migration = {
console.log(`[Migration] Custom JSON: copied custom to customJson (preserved old key)`);
}
};
const MCP_DEFAULT_ENABLED_MIGRATION_ID = 'mcp-default-enabled-to-config-v1';
const LEGACY_MCP_DEFAULT_ENABLED_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
const DEPRECATED_LEGACY_MCP_DEFAULT_ENABLED_KEY = `${STORAGE_APP_NAME_DEPRECATED}.mcpDefaultEnabled`;
const mcpDefaultEnabledMigration: Migration = {
id: MCP_DEFAULT_ENABLED_MIGRATION_ID,
description:
'Copy mcpDefaultEnabled localStorage key into settings config (preserves legacy keys)',
id: MCP_DEFAULT_ENABLED_MIGRATION_ID,
async run(): Promise<void> {
const raw =
@@ -515,6 +554,7 @@ const mcpDefaultEnabledMigration: Migration = {
if (raw === null) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] MCP default enabled: no legacy key found, skipping');
return;
}
@@ -525,12 +565,15 @@ const mcpDefaultEnabledMigration: Migration = {
if (MCP_DEFAULT_OVERRIDES_LEGACY_KEY in config) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] MCP default enabled: config already has overrides, skipping');
return;
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return;
const valid = parsed.every(
(o) =>
typeof o === 'object' &&
@@ -538,6 +581,7 @@ const mcpDefaultEnabledMigration: Migration = {
typeof (o as Record<string, unknown>).serverId === 'string' &&
typeof (o as Record<string, unknown>).enabled === 'boolean'
);
if (!valid) return;
} catch {
return;
@@ -550,18 +594,18 @@ const mcpDefaultEnabledMigration: Migration = {
console.log('[Migration] MCP default enabled: moved legacy key into config');
}
};
const CONFIG_TYPES_MIGRATION_ID = 'config-type-normalization-v1';
const configTypesMigration: Migration = {
id: CONFIG_TYPES_MIGRATION_ID,
description: 'Coerce legacy string-encoded booleans in persisted config to real booleans',
id: CONFIG_TYPES_MIGRATION_ID,
async run(): Promise<void> {
const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
if (configRaw === null) return;
const config = JSON.parse(configRaw);
let changed = false;
// Pre-schema configs persisted booleans as "true"/"false" strings; the strict server
@@ -585,10 +629,8 @@ const configTypesMigration: Migration = {
console.log(`[Migration] Config types: coerced string booleans (changed=${changed})`);
}
};
const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
/**
* Folds `mcpDefaultServerOverrides` (the legacy "default for new chats" list,
* JSON-encoded as `[{ serverId, enabled }, ...]`) into `mcpServers[i].enabled`.
@@ -597,12 +639,13 @@ const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1
* standalone overrides are already inside the config.
*/
const mcpDefaultOverridesMergeMigration: Migration = {
id: MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID,
description:
'Merge mcpDefaultServerOverrides entries onto mcpServers[i].enabled (preserves legacy key)',
id: MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID,
async run(): Promise<void> {
const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
if (configRaw === null) return;
const config = JSON.parse(configRaw);
@@ -611,13 +654,17 @@ const mcpDefaultOverridesMergeMigration: Migration = {
if (typeof raw !== 'string' || raw.length === 0) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] MCP default overrides merge: nothing to merge');
return;
}
let overrides: { serverId: string; enabled: boolean }[];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return;
overrides = parsed.filter(
(o) =>
typeof o === 'object' &&
@@ -630,7 +677,9 @@ const mcpDefaultOverridesMergeMigration: Migration = {
}
const serversRaw = config[SETTINGS_KEYS.MCP_SERVERS];
let servers: { id: string; enabled?: boolean }[];
try {
servers = typeof serversRaw === 'string' ? JSON.parse(serversRaw) : [];
} catch {
@@ -640,9 +689,12 @@ const mcpDefaultOverridesMergeMigration: Migration = {
if (!Array.isArray(servers)) servers = [];
let serversChanged = false;
const knownIds = new Set(servers.map((s) => s.id));
for (const override of overrides) {
if (!knownIds.has(override.serverId)) continue;
const index = servers.findIndex((s) => s.id === override.serverId);
if (index >= 0 && servers[index].enabled !== override.enabled) {
@@ -662,7 +714,6 @@ const mcpDefaultOverridesMergeMigration: Migration = {
);
}
};
const migrations: Migration[] = [
localStorageMigration,
idxdbMigration,
@@ -682,13 +733,6 @@ export const MigrationService = {
return [...migrations];
},
/**
* Check if a specific migration has been completed
*/
isCompleted(id: string): boolean {
return isMigrationCompleted(id);
},
/**
* Get current migration state
*/
@@ -696,11 +740,19 @@ export const MigrationService = {
return getMigrationState();
},
/**
* Check if a specific migration has been completed
*/
isCompleted(id: string): boolean {
return isMigrationCompleted(id);
},
/**
* Reset migration state (use with caution - migrations will run again)
*/
resetState(): void {
localStorage.removeItem(MIGRATION_STATE_KEY);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] State reset - all migrations will run again');
},
@@ -711,6 +763,7 @@ export const MigrationService = {
*/
async runAllMigrations(): Promise<void> {
const state = getMigrationState();
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Starting migration run, state:', state);
@@ -718,14 +771,17 @@ export const MigrationService = {
if (isMigrationCompleted(migration.id)) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] ${migration.id}: already completed, skipping`);
continue;
}
try {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] ${migration.id}: running...`);
await migration.run();
markMigrationCompleted(migration.id);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] ${migration.id}: completed successfully`);
} catch (error) {
+18 -16
View File
@@ -1,19 +1,19 @@
import { ServerModelStatus } from '$lib/enums';
import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
import type { ParsedModelId } from '$lib/types/models';
import {
MODEL_QUANTIZATION_SEGMENT_RE,
MODEL_CUSTOM_QUANTIZATION_PREFIX_RE,
MODEL_PARAMS_RE,
API_MODELS,
MODEL_ACTIVATED_PARAMS_RE,
MODEL_IGNORED_SEGMENTS,
MODEL_WEIGHT_EXTENSION_RE,
MODEL_CUSTOM_QUANTIZATION_PREFIX_RE,
MODEL_ID_NOT_FOUND,
MODEL_ID_ORG_SEPARATOR,
MODEL_ID_SEGMENT_SEPARATOR,
MODEL_ID_QUANTIZATION_SEPARATOR,
API_MODELS
MODEL_ID_SEGMENT_SEPARATOR,
MODEL_IGNORED_SEGMENTS,
MODEL_PARAMS_RE,
MODEL_QUANTIZATION_SEGMENT_RE,
MODEL_WEIGHT_EXTENSION_RE
} from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import type { ParsedModelId } from '$lib/types/models';
import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
export class ModelsService {
/**
@@ -64,6 +64,7 @@ export class ModelsService {
*/
static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
const payload: { model: string; extra_args?: string[] } = { model: modelId };
if (extraArgs && extraArgs.length > 0) {
payload.extra_args = extraArgs;
}
@@ -131,21 +132,20 @@ export class ModelsService {
*/
static parseModelId(modelId: string): ParsedModelId {
const result: ParsedModelId = {
raw: modelId,
orgName: null,
modelName: null,
params: null,
activatedParams: null,
modelName: null,
orgName: null,
params: null,
quantization: null,
raw: modelId,
tags: []
};
// strip directory path and weight extension so a bare `-m /path/file.gguf`
// parses like a clean repo id; the HF `org/model` form is preserved
const source = normalizeModelName(modelId).replace(MODEL_WEIGHT_EXTENSION_RE, '');
// 1. Extract colon-separated quantization (e.g. `model:Q4_K_M`)
const colonIdx = source.indexOf(MODEL_ID_QUANTIZATION_SEPARATOR);
let modelPath: string;
if (colonIdx !== MODEL_ID_NOT_FOUND) {
@@ -157,6 +157,7 @@ export class ModelsService {
// 2. Extract org name (e.g. `org/model` -> org = "org")
const slashIdx = modelPath.indexOf(MODEL_ID_ORG_SEPARATOR);
let modelStr: string;
if (slashIdx !== MODEL_ID_NOT_FOUND) {
@@ -222,6 +223,7 @@ export class ModelsService {
if (paramsIdx !== MODEL_ID_NOT_FOUND) {
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
const absIdx = paramsIdx + 1 + relIdx;
if (absIdx === activatedParamsIdx) return false;
return !MODEL_IGNORED_SEGMENTS.has(segments[absIdx].toUpperCase());
@@ -1,64 +1,63 @@
import { describe, it, expect } from 'vitest';
import { ParameterSyncService } from './parameter-sync.service';
import { describe, expect, it } from 'vitest';
describe('ParameterSyncService', () => {
describe('roundFloatingPoint', () => {
it('should fix JavaScript floating-point precision issues', () => {
// Test the specific values from the screenshot
const mockServerParams = {
top_p: 0.949999988079071,
min_p: 0.009999999776482582,
samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature'],
temperature: 0.800000011920929,
top_k: 40,
samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature']
top_p: 0.949999988079071
};
const result = ParameterSyncService.extractServerDefaults({
...mockServerParams,
// Add other required fields to match the API type
n_predict: 512,
seed: -1,
dynatemp_range: 0.0,
dynatemp_exponent: 1.0,
xtc_probability: 0.0,
xtc_threshold: 0.1,
typ_p: 1.0,
repeat_last_n: 64,
repeat_penalty: 1.0,
presence_penalty: 0.0,
frequency_penalty: 0.0,
dry_multiplier: 0.0,
dry_base: 1.75,
chat_format: '',
dry_allowed_length: 2,
dry_base: 1.75,
dry_multiplier: 0.0,
dry_penalty_last_n: 64,
mirostat: 0,
mirostat_tau: 5.0,
mirostat_eta: 0.1,
stop: [],
max_tokens: -1,
n_keep: 0,
n_discard: 0,
ignore_eos: false,
stream: true,
logit_bias: [],
n_probs: 0,
min_keep: 0,
dry_sequence_breakers: [],
dynatemp_exponent: 1.0,
dynatemp_range: 0.0,
frequency_penalty: 0.0,
generation_prompt: '',
grammar: '',
grammar_lazy: false,
grammar_triggers: [],
ignore_eos: false,
logit_bias: [],
lora: [],
max_tokens: -1,
min_keep: 0,
mirostat: 0,
mirostat_eta: 0.1,
mirostat_tau: 5.0,
n_discard: 0,
n_keep: 0,
// Add other required fields to match the API type
n_predict: 512,
n_probs: 0,
post_sampling_probs: false,
presence_penalty: 0.0,
preserved_tokens: [],
chat_format: '',
reasoning_format: '',
reasoning_in_content: false,
generation_prompt: '',
repeat_last_n: 64,
repeat_penalty: 1.0,
seed: -1,
'speculative.n_max': 0,
'speculative.n_min': 0,
'speculative.p_min': 0.0,
stop: [],
stream: true,
timings_per_token: false,
post_sampling_probs: false,
lora: [],
top_n_sigma: 0.0,
dry_sequence_breakers: []
typ_p: 1.0,
xtc_probability: 0.0,
xtc_threshold: 0.1
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
// Check that the problematic floating-point values are rounded correctly
@@ -71,59 +70,58 @@ describe('ParameterSyncService', () => {
it('should preserve non-numeric values', () => {
const mockServerParams = {
samplers: ['top_k', 'temperature'],
max_tokens: -1,
samplers: ['top_k', 'temperature'],
temperature: 0.7
};
const result = ParameterSyncService.extractServerDefaults({
...mockServerParams,
// Minimal required fields
n_predict: 512,
seed: -1,
dynatemp_range: 0.0,
dynatemp_exponent: 1.0,
top_k: 40,
top_p: 0.95,
min_p: 0.05,
xtc_probability: 0.0,
xtc_threshold: 0.1,
typ_p: 1.0,
repeat_last_n: 64,
repeat_penalty: 1.0,
presence_penalty: 0.0,
frequency_penalty: 0.0,
dry_multiplier: 0.0,
dry_base: 1.75,
chat_format: '',
dry_allowed_length: 2,
dry_base: 1.75,
dry_multiplier: 0.0,
dry_penalty_last_n: 64,
mirostat: 0,
mirostat_tau: 5.0,
mirostat_eta: 0.1,
stop: [],
n_keep: 0,
n_discard: 0,
ignore_eos: false,
stream: true,
logit_bias: [],
n_probs: 0,
min_keep: 0,
dry_sequence_breakers: [],
dynatemp_exponent: 1.0,
dynatemp_range: 0.0,
frequency_penalty: 0.0,
generation_prompt: '',
grammar: '',
grammar_lazy: false,
grammar_triggers: [],
ignore_eos: false,
logit_bias: [],
lora: [],
min_keep: 0,
min_p: 0.05,
mirostat: 0,
mirostat_eta: 0.1,
mirostat_tau: 5.0,
n_discard: 0,
n_keep: 0,
// Minimal required fields
n_predict: 512,
n_probs: 0,
post_sampling_probs: false,
presence_penalty: 0.0,
preserved_tokens: [],
chat_format: '',
reasoning_format: '',
reasoning_in_content: false,
generation_prompt: '',
repeat_last_n: 64,
repeat_penalty: 1.0,
seed: -1,
'speculative.n_max': 0,
'speculative.n_min': 0,
'speculative.p_min': 0.0,
stop: [],
stream: true,
timings_per_token: false,
post_sampling_probs: false,
lora: [],
top_k: 40,
top_n_sigma: 0.0,
dry_sequence_breakers: []
top_p: 0.95,
typ_p: 1.0,
xtc_probability: 0.0,
xtc_threshold: 0.1
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
expect(result.samplers).toBe('top_k;temperature');
@@ -1,7 +1,7 @@
import { normalizeFloatingPoint } from '$lib/utils';
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
import type { ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
import { SyncableParameterType, ParameterSource } from '$lib/enums';
import { ParameterSource, SyncableParameterType } from '$lib/enums';
import type { ParameterInfo, ParameterRecord, ParameterValue } from '$lib/types';
import { normalizeFloatingPoint } from '$lib/utils';
export class ParameterSyncService {
/**
@@ -42,6 +42,7 @@ export class ParameterSyncService {
const value = (serverParams as unknown as Record<string, ParameterValue>)[
param.serverKey
];
if (value !== undefined) {
// Apply precision rounding to avoid JavaScript floating-point issues
extracted[param.key] = this.roundFloatingPoint(value);
@@ -120,15 +121,14 @@ export class ParameterSyncService {
): ParameterInfo {
const hasPropsDefault = propsDefaults[key] !== undefined;
const isUserOverride = userOverrides.has(key);
// Simple logic: either using default (from props) or custom (user override)
const source = isUserOverride ? ParameterSource.CUSTOM : ParameterSource.DEFAULT;
return {
value: currentValue,
source,
serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
userOverride: isUserOverride ? currentValue : undefined
source,
userOverride: isUserOverride ? currentValue : undefined,
value: currentValue
};
}
@@ -160,6 +160,7 @@ export class ParameterSyncService {
*/
static validateServerParameter(key: string, value: ParameterValue): boolean {
const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
if (!param) return false;
switch (param.type) {
@@ -207,8 +208,8 @@ export class ParameterSyncService {
if (serverValue !== undefined) {
diff[key] = {
current: currentValue,
server: serverValue,
differs: currentValue !== serverValue
differs: currentValue !== serverValue,
server: serverValue
};
}
}
@@ -20,6 +20,7 @@ export class PropsService {
*/
static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
const params: Record<string, string> = {};
if (!autoload) {
params.autoload = 'false';
}
@@ -38,6 +39,7 @@ export class PropsService {
*/
static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
const params: Record<string, string> = { model: modelId };
if (!autoload) {
params.autoload = 'false';
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { NEWLINE } from '$lib/constants';
import WORKER_SHIM from './sandbox-worker.js?raw';
import { NEWLINE } from '$lib/constants';
/**
* CSP for the harness document, inherited by the blob worker. connect-src
+11 -6
View File
@@ -1,3 +1,4 @@
import { buildSandboxHarness } from './sandbox-harness';
import {
NEWLINE,
SANDBOX_EMPTY_OUTPUT,
@@ -7,7 +8,6 @@ import {
SANDBOX_TOOL_NAME,
SANDBOX_TRUNCATION_NOTICE
} from '$lib/constants';
import { buildSandboxHarness } from './sandbox-harness';
import { config } from '$lib/stores/settings.svelte';
import type { ToolExecutionResult } from '$lib/types';
@@ -22,14 +22,17 @@ const harnessCache: Record<string, string> = {};
async function getHarness(): Promise<string> {
const enabled = !!config().symbolicMathEnabled;
const key = enabled ? 'nerdamer' : 'plain';
if (!harnessCache[key]) {
if (enabled) {
const { default: nerdamerJs } = await import('virtual:nerdamer');
harnessCache[key] = buildSandboxHarness(nerdamerJs);
} else {
harnessCache[key] = buildSandboxHarness('');
}
}
return harnessCache[key];
}
@@ -53,7 +56,9 @@ function formatReply(reply: SandboxReply): ToolExecutionResult {
}
let content = lines.join(NEWLINE);
if (!content) content = SANDBOX_EMPTY_OUTPUT;
if (content.length > SANDBOX_OUTPUT_MAX_CHARS) {
content = `${content.slice(0, SANDBOX_OUTPUT_MAX_CHARS)}${NEWLINE}${SANDBOX_TRUNCATION_NOTICE}`;
}
@@ -78,12 +83,12 @@ export class SandboxService {
}
const code = typeof params.code === 'string' ? params.code : '';
if (!code) {
return { content: 'Missing required parameter: code', isError: true };
}
const harness = await getHarness();
const requested = Number(params.timeout_ms);
const timeoutMs =
Number.isFinite(requested) && requested > 0
@@ -92,6 +97,7 @@ export class SandboxService {
return new Promise<ToolExecutionResult>((resolve, reject) => {
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.style.display = 'none';
iframe.srcdoc = harness;
@@ -105,24 +111,23 @@ export class SandboxService {
signal?.removeEventListener('abort', onAbort);
iframe.remove();
};
const finish = (result: ToolExecutionResult) => {
if (settled) return;
cleanup();
resolve(result);
};
const onAbort = () => {
if (settled) return;
cleanup();
reject(new DOMException('Sandbox execution aborted', 'AbortError'));
};
const onMessage = (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) return;
finish(formatReply((event.data ?? {}) as SandboxReply));
};
const timer = setTimeout(
() => finish({ content: `Execution timed out after ${timeoutMs} ms`, isError: true }),
timeoutMs
+22 -10
View File
@@ -1,10 +1,10 @@
import { base } from '$app/paths';
import { getJsonHeaders } from '$lib/utils/api-headers';
import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
import { apiFetch } from '$lib/utils';
import { API_TOOLS, X_TOOL_CWD_HEADER } from '$lib/constants';
import { ToolResponseField } from '$lib/enums';
import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types';
import type { ServerBuiltinToolInfo, ToolExecutionResult } from '$lib/types';
import { apiFetch } from '$lib/utils';
import { getJsonHeaders } from '$lib/utils/api-headers';
import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
export class ToolsService {
/**
@@ -30,9 +30,9 @@ export class ToolsService {
cwd?: string
): Promise<ToolExecutionResult> {
const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
method: 'POST',
body: JSON.stringify({ tool: toolName, params }),
body: JSON.stringify({ params, tool: toolName }),
headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
method: 'POST',
signal
});
@@ -59,9 +59,9 @@ export class ToolsService {
cwd?: string
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
method: 'POST',
body: JSON.stringify({ tool: toolName, params }),
body: JSON.stringify({ params, tool: toolName }),
headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
method: 'POST',
signal
});
}
@@ -88,16 +88,19 @@ export class ToolsService {
cwd?: string
): AsyncGenerator<ToolStreamEvent> {
const headers = getJsonHeaders();
if (cwd) headers[X_TOOL_CWD_HEADER] = cwd;
const response = await fetch(`${base}${API_TOOLS.EXECUTE}`, {
method: 'POST',
body: JSON.stringify({ params, stream: true, tool: toolName }),
headers,
body: JSON.stringify({ tool: toolName, params, stream: true }),
method: 'POST',
signal
});
if (!response.ok || !response.body) {
const detail = await formatNonOkResponse(response);
throw new Error(detail);
}
@@ -105,14 +108,18 @@ export class ToolsService {
while (true) {
const next: IteratorResult<SseJsonEvent<ToolServerEvent>> = await iterator.next();
if (next.done) return;
const event = next.value.data;
if (event.chunk !== undefined) {
yield { chunk: event.chunk, done: false };
}
if (event.done) {
yield { chunk: null, done: true, error: event.error };
return;
}
}
@@ -140,18 +147,23 @@ interface ToolServerEvent {
async function formatNonOkResponse(response: Response): Promise<string> {
const status = `${response.status} ${response.statusText}`.trim();
try {
const errBody = (await response.clone().json()) as { error?: string; message?: string };
if (errBody?.error) return `${status}: ${errBody.error}`;
if (errBody?.message) return `${status}: ${errBody.message}`;
} catch (error) {
console.error('[tools] Non-JSON error response, falling back to raw text:', error);
try {
const text = await response.text();
if (text.trim()) return `${status}: ${text.trim()}`;
} catch (error) {
console.error('[tools] Failed to read error response as text:', error);
}
}
return status || `HTTP ${response.status}`;
}