ui: Linting & Formatting scripts (#26819)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user