mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-12 22:31:11 +04:00
* server: add read_image tool (#25875) Adds a server-tool that allows vision models to analyze server-side images. This tool is reading a single file for now: The image data is base64 encoded and passed to the UI, which decodes it, fills the <img> tag and removes the data URI before passing the tool result back to the model. * cleanup read_image tool: move magic strings to constants * Add dedicated constants file: tools/ui/src/lib/constants/read-image.ts with PREFIX_IMAGE, PREFIX_SIZE, PREFIX_MIME constants * Use ATTACHMENT_SAVED_REGEX from agentic.ts in ChatMessageToolCallBlockReadImage.svelte * Use NEWLINE constant from code.ts instead of hardcoded '\n' * Use PREFIX_SIZE in regex pattern for size parsing * Add SERVER_TOOL_READ_IMAGE_PREFIX_* constants in C++ server-tools.cpp to match the TypeScript PREFIX_* constants for consistency * server: rename read_image tool to read_media for images and audio * Rename server_tool_read_image to server_tool_read_media in C++ * Rename enum BuiltInTool.READ_IMAGE to READ_MEDIA * Rename UI constants, parser, and Svelte component files * Update display label from 'Read image' to 'Read media' * ui: consolidate audio data URI handling into shared utility * Extract getAudioInputFormat to a shared utility (was duplicated inline) * Store raw base64 in base64Data on the message object * Use base64Data to construct data URIs for audio rendering * Update agentic store to build INPUT_AUDIO parts from base64Data * server: read_media: restrict audio to wav/mp3 and minor fixes * Server get_mime_from_extension now only advertises audio/wav and audio/mpeg (the only formats the model's input_audio API accepts) * Case-insensitive extension matching (fixes .MP3, .Wav, etc.) * Unknown extensions return an error instead of a multi-MB data URI that inflates model context with garbage * Updated tool description to document supported formats * Frontend AUDIO_MIME_TO_EXTENSION trimmed to match server * fix a missing import in tools/ui/src/lib/stores/agentic.svelte.ts * server: read_media: add to --tools help text and README tool list * ui: fix indentation in ChatMessageToolCallBlockDefault.svelte * server: read_media tool: fix a cast to use the correct type * server: read_media: multiple fixes * server-tools.cpp import cctype, remove UTF-8 char, check mime before reading file * ui: add MimeTypePrefix.AUDIO and use it in agentic.svelte.ts * server: make read_media inherit from read_file and add uses_cwd * ui: fix formating issues * rm from server * move it to frontend-only tool * correct partial commit * rm unused * ui: address review from allozaur Replace the magic strings, regexes and number in the read_media parser and service with named constants. Path splitting reuses FILE_PATH_SEPARATOR_REGEX, the size header regex moves to READ_MEDIA_SIZE_REGEX derived from PREFIX_SIZE, and FILE_EXTENSION_SEPARATOR lands next to it in constants/code.ts. --------- Co-authored-by: ckrafft <ckrafft@epyc> Co-authored-by: Xuan Son Nguyen <son@huggingface.co> Co-authored-by: Pascal <admin@serveurperso.com>
1586 lines
45 KiB
TypeScript
1586 lines
45 KiB
TypeScript
import { settingsStore } from '../stores/settings.svelte';
|
|
import { getAudioInputFormat } from '../utils/audio-format';
|
|
import { capImageDataURLSize } from '../utils/cap-img-size';
|
|
import {
|
|
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,
|
|
SSE_DATA_PREFIX,
|
|
SSE_DONE_MARKER,
|
|
SSE_LINE_SEPARATOR,
|
|
STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX,
|
|
STREAM_VISIBILITY_KICK_MS
|
|
} from '$lib/constants';
|
|
import {
|
|
AttachmentType,
|
|
ContentPartType,
|
|
MessageRole,
|
|
ReasoningFormat,
|
|
StreamConnectionState
|
|
} from '$lib/enums';
|
|
import { modelsStore } from '$lib/stores/models.svelte';
|
|
import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
|
|
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';
|
|
|
|
interface ResumableStreamState {
|
|
bytesReceived: number;
|
|
updatedAt: number;
|
|
|
|
// model frozen at POST time, lets a reload rebuild the exact conv::model identity the
|
|
// server keyed the session under. null when the POST carried no explicit model
|
|
model?: string | null;
|
|
}
|
|
|
|
function streamStorageKey(conversationId: string): string {
|
|
return STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX + conversationId;
|
|
}
|
|
|
|
export class ChatService {
|
|
/**
|
|
*
|
|
*
|
|
* Title Generation
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Sends a streaming chat completion request for generating a chat title.
|
|
* Delegates to `sendMessage` for fetch, SSE parsing, and error handling.
|
|
*
|
|
* @param message - The single message to send (a user message containing the title generation prompt)
|
|
* @param model - Optional model name to use (required in ROUTER mode)
|
|
* @param signal - Optional AbortSignal to cancel the request
|
|
* @returns {Promise<string>} The aggregated title text, or empty string if request failed
|
|
* @static
|
|
*/
|
|
static async generateTitle(
|
|
message: ApiChatMessageData,
|
|
model?: string | null,
|
|
signal?: AbortSignal
|
|
): Promise<string> {
|
|
let titleResponse = '';
|
|
|
|
try {
|
|
await ChatService.sendMessage(
|
|
[message],
|
|
{
|
|
custom: { chat_template_kwargs: { enable_thinking: false } },
|
|
model: model || undefined,
|
|
onChunk: (chunk: string) => {
|
|
titleResponse += chunk;
|
|
},
|
|
stream: true
|
|
},
|
|
undefined,
|
|
signal
|
|
);
|
|
} catch {
|
|
return '';
|
|
}
|
|
|
|
return titleResponse;
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* Messaging
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Sends a chat completion request to the llama-server.
|
|
* Supports both streaming and non-streaming responses with comprehensive parameter configuration.
|
|
* Automatically converts database messages with attachments to the appropriate API format.
|
|
*
|
|
* @param messages - Array of chat messages to send to the API (supports both ApiChatMessageData and DatabaseMessage with attachments)
|
|
* @param options - Configuration options for the chat completion request. See `SettingsChatServiceOptions` type for details.
|
|
* @returns {Promise<string | void>} that resolves to the complete response string (non-streaming) or void (streaming)
|
|
* @throws {Error} if the request fails or is aborted
|
|
*/
|
|
static async sendMessage(
|
|
messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
|
|
options: SettingsChatServiceOptions = {},
|
|
conversationId?: string,
|
|
signal?: AbortSignal
|
|
): Promise<string | void> {
|
|
const {
|
|
backend_sampling,
|
|
continueFinalMessage,
|
|
custom,
|
|
// Config options
|
|
disableReasoningParsing,
|
|
dry_allowed_length,
|
|
dry_base,
|
|
dry_multiplier,
|
|
dry_penalty_last_n,
|
|
dynatemp_exponent,
|
|
// Sampling parameters
|
|
dynatemp_range,
|
|
enableThinking,
|
|
excludeReasoningFromContext,
|
|
frequency_penalty,
|
|
max_tokens,
|
|
min_p,
|
|
onChunk,
|
|
onComplete,
|
|
onCompletionId,
|
|
onConnectionState,
|
|
onError,
|
|
onModel,
|
|
onReasoningChunk,
|
|
onTimings,
|
|
onToolCallChunk,
|
|
presence_penalty,
|
|
reasoningEffort,
|
|
// Penalty parameters
|
|
repeat_last_n,
|
|
repeat_penalty,
|
|
// Other parameters
|
|
samplers,
|
|
stream,
|
|
// Generation parameters
|
|
temperature,
|
|
timings_per_token,
|
|
// 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) => {
|
|
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
|
|
const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
|
|
|
|
return ChatService.convertDbMessageToApiChatMessageData(dbMsg);
|
|
} else {
|
|
return msg as ApiChatMessageData;
|
|
}
|
|
})
|
|
)
|
|
).filter((msg: { role: ChatRole; content: string | ApiChatMessageContentPart[] }) => {
|
|
// Filter out empty system messages
|
|
if (msg.role === MessageRole.SYSTEM) {
|
|
const content = typeof msg.content === 'string' ? msg.content : '';
|
|
|
|
return content.trim().length > 0;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// Filter out image attachments if the model doesn't support vision
|
|
if (options.model && !modelsStore.modelSupportsVision(options.model)) {
|
|
normalizedMessages.forEach((msg) => {
|
|
if (Array.isArray(msg.content)) {
|
|
msg.content = msg.content.filter((part: ApiChatMessageContentPart) => {
|
|
if (part.type === ContentPartType.IMAGE_URL) {
|
|
console.info(
|
|
`[ChatService] Skipping image attachment in message history (model "${options.model}" does not support vision)`
|
|
);
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// If only text remains and it's a single part, simplify to string
|
|
if (
|
|
msg.content.length === 1 &&
|
|
msg.content[0].type === ContentPartType.TEXT &&
|
|
typeof msg.content[0].text === 'string'
|
|
) {
|
|
msg.content = msg.content[0].text;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const requestBody: ApiChatCompletionRequest = {
|
|
messages: normalizedMessages.map((msg: ApiChatMessageData) => {
|
|
const mapped: ApiChatCompletionRequest['messages'][0] = {
|
|
content: msg.content,
|
|
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;
|
|
}),
|
|
return_progress: stream ? true : undefined,
|
|
sse_ping_interval: stream ? 1 : undefined,
|
|
stream,
|
|
tools: tools && tools.length > 0 ? tools : undefined
|
|
};
|
|
|
|
// Include model in request if provided (required in ROUTER mode)
|
|
if (options.model) {
|
|
requestBody.model = options.model;
|
|
}
|
|
|
|
requestBody.reasoning_format = disableReasoningParsing
|
|
? ReasoningFormat.NONE
|
|
: ReasoningFormat.AUTO;
|
|
|
|
const reasoningBudgetTokens =
|
|
enableThinking && reasoningEffort ? (REASONING_EFFORT_TOKENS[reasoningEffort] ?? -1) : -1;
|
|
|
|
// an explicit user choice injects the kwarg, otherwise it is omitted so
|
|
// the server default applies (--reasoning flag or chat template)
|
|
if (enableThinking !== undefined) {
|
|
requestBody.chat_template_kwargs = {
|
|
...(requestBody.chat_template_kwargs ?? {}),
|
|
enable_thinking: enableThinking
|
|
};
|
|
}
|
|
|
|
if (reasoningBudgetTokens >= 0) {
|
|
requestBody.thinking_budget_tokens = reasoningBudgetTokens;
|
|
}
|
|
|
|
// arms the budget sampler so reasoning can be ended at runtime via the control endpoint
|
|
requestBody.reasoning_control = true;
|
|
|
|
if (continueFinalMessage) {
|
|
requestBody.continue_final_message = true;
|
|
requestBody.add_generation_prompt = false;
|
|
}
|
|
|
|
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) {
|
|
requestBody.samplers =
|
|
typeof samplers === 'string'
|
|
? samplers.split(';').filter((s: string) => s.trim())
|
|
: samplers;
|
|
}
|
|
|
|
if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling;
|
|
|
|
if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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
|
|
if (stream && conversationId) {
|
|
headers['X-Conversation-Id'] = streamIdentity(conversationId, options.model);
|
|
// persist the pending stream before the fetch: a reload during the model load or
|
|
// the prompt processing must still find its way back to the session once it exists
|
|
ChatService.saveStreamState(conversationId, 0, options.model ?? null);
|
|
}
|
|
|
|
const response = await fetch(API_CHAT.COMPLETIONS, {
|
|
body: JSON.stringify(requestBody),
|
|
headers,
|
|
method: 'POST',
|
|
signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// a rejected request (including one cancelled by a stop during the model load)
|
|
// leaves nothing to resume
|
|
if (conversationId) {
|
|
ChatService.clearStreamState(conversationId);
|
|
}
|
|
|
|
const error = await ChatService.parseErrorResponse(response);
|
|
|
|
if (onError) {
|
|
onError(error);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
if (stream) {
|
|
await ChatService.handleStreamResponse(
|
|
response,
|
|
onChunk,
|
|
onComplete,
|
|
onError,
|
|
onReasoningChunk,
|
|
onToolCallChunk,
|
|
onModel,
|
|
onCompletionId,
|
|
onTimings,
|
|
conversationId,
|
|
signal,
|
|
onConnectionState,
|
|
options.model
|
|
);
|
|
|
|
return;
|
|
} else {
|
|
return ChatService.handleNonStreamResponse(
|
|
response,
|
|
onComplete,
|
|
onError,
|
|
onToolCallChunk,
|
|
onModel
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
console.log('Chat completion request was aborted');
|
|
|
|
return;
|
|
}
|
|
|
|
let userFriendlyError: Error;
|
|
|
|
if (error instanceof Error) {
|
|
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
userFriendlyError = new Error(
|
|
'Unable to connect to server - please check if the server is running'
|
|
);
|
|
userFriendlyError.name = 'NetworkError';
|
|
} else if (error.message.includes('ECONNREFUSED')) {
|
|
userFriendlyError = new Error('Connection refused - server may be offline');
|
|
userFriendlyError.name = 'NetworkError';
|
|
} else if (error.message.includes('ETIMEDOUT')) {
|
|
userFriendlyError = new Error('Request timed out - the server took too long to respond');
|
|
userFriendlyError.name = 'TimeoutError';
|
|
} else {
|
|
userFriendlyError = error;
|
|
}
|
|
} else {
|
|
userFriendlyError = new Error('Unknown error occurred while sending message');
|
|
}
|
|
|
|
console.error('Error in sendMessage:', error);
|
|
|
|
if (onError) {
|
|
onError(userFriendlyError);
|
|
}
|
|
|
|
throw userFriendlyError;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks whether all server slots are currently idle (not processing any requests).
|
|
* Queries the /slots endpoint (requires --slots flag on the server).
|
|
* Returns true if all slots are idle, false if any is processing.
|
|
* If the endpoint is unavailable or errors out, returns true (best-effort fallback).
|
|
*
|
|
* @param signal - Optional AbortSignal to cancel the request if needed
|
|
* @param model - Optional model name to check slots for (required in ROUTER mode)
|
|
* @returns {Promise<boolean>} Promise that resolves to true if all slots are idle, false if any is processing
|
|
*/
|
|
static async areAllSlotsIdle(model?: string | null, signal?: AbortSignal): Promise<boolean> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ends the current reasoning block of a running completion, targeted by its
|
|
* chat completion id (streamed back as `id`). Matching the completion rather
|
|
* than a slot index avoids a TOCTOU: a finished completion simply matches
|
|
* nothing server side. The model is carried so the router forwards to the
|
|
* right child, single model ignores it. Returns true on success.
|
|
*/
|
|
static async stopReasoning(completionId: string, model?: string | null): Promise<boolean> {
|
|
if (!completionId) {
|
|
console.error(
|
|
'stopReasoning: no completion id for the active message, cannot target the running completion'
|
|
);
|
|
|
|
return false;
|
|
}
|
|
|
|
const body: Record<string, unknown> = {
|
|
action: CONTROL_ACTION.END_REASONING,
|
|
id: completionId
|
|
};
|
|
|
|
if (model) body.model = model;
|
|
|
|
try {
|
|
const res = await fetch(API_CHAT.CONTROL, {
|
|
body: JSON.stringify(body),
|
|
headers: getJsonHeaders(),
|
|
method: 'POST'
|
|
});
|
|
const data = await res.json().catch(() => null);
|
|
|
|
if (!res.ok || data?.success !== true) {
|
|
console.error('stopReasoning: control request failed', {
|
|
completionId,
|
|
response: data,
|
|
status: res.status
|
|
});
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('stopReasoning: control request threw', { completionId, error });
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a fire-and-forget request to pre-encode the conversation in the server's KV cache.
|
|
* After a response completes, this re-submits the full conversation
|
|
* using n_predict=0 and stream=false so the server processes the prompt without generating tokens.
|
|
* This warms the cache for the next turn, making it faster.
|
|
*
|
|
* When excludeReasoningFromContext is true, reasoning content is stripped from the messages
|
|
* to match what sendMessage would send on the next turn (avoiding cache misses).
|
|
* When false, reasoning_content is preserved so the cached prompt matches the next request.
|
|
*
|
|
* @param messages - The full conversation including the latest assistant response
|
|
* @param model - Optional model name (required in ROUTER mode)
|
|
* @param excludeReasoning - Whether to strip reasoning content (should match excludeReasoningFromContext setting)
|
|
* @param signal - Optional AbortSignal to cancel the pre-encode request
|
|
*/
|
|
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)}`, {
|
|
headers: getAuthHeaders(),
|
|
method: 'DELETE'
|
|
});
|
|
} catch (e) {
|
|
console.warn('cancelServerStream failed:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pick the running session to splice into when discoverActiveStream lists candidates for a
|
|
* conversation. Finalized sessions are not candidates: their final content was already written
|
|
* to the DB by the original onComplete handler, so attaching to them would replay a buffer that
|
|
* may not match what the DB holds. A continue session's buffer holds only the appended deltas,
|
|
* not the pre continue prefix, so replaying it as a fresh generation would erase the original.
|
|
*
|
|
* Among running sessions we tie break on the most recent started_at, which covers the case of
|
|
* multiple inferences left running on the same conversation.
|
|
*/
|
|
static selectActiveStream(
|
|
sessions: ApiStreamSession[] | null | undefined
|
|
): ApiStreamSession | null {
|
|
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));
|
|
}
|
|
|
|
// persist the running byte count and the frozen model for a conversation, a later visit
|
|
// resumes the SSE replay at the right offset under the same conv::model identity
|
|
static saveStreamState(
|
|
conversationId: string,
|
|
bytesReceived: number,
|
|
model?: string | null
|
|
): void {
|
|
if (!conversationId) return;
|
|
|
|
try {
|
|
const state: ResumableStreamState = {
|
|
bytesReceived,
|
|
model: model ?? null,
|
|
updatedAt: Date.now()
|
|
};
|
|
|
|
localStorage.setItem(streamStorageKey(conversationId), JSON.stringify(state));
|
|
} catch {
|
|
// localStorage may be full or disabled, silently ignore
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
static clearStreamState(conversationId: string): void {
|
|
if (!conversationId) return;
|
|
|
|
try {
|
|
localStorage.removeItem(streamStorageKey(conversationId));
|
|
} catch {
|
|
// nothing to do
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rebuild the stream identity for a resume. The model persisted at POST time wins, including a
|
|
* stored null which means the POST carried no explicit model so the identity stays the bare conv
|
|
* id. Only fall back to the caller supplied current model when nothing was persisted.
|
|
*/
|
|
static resumeStreamIdentity(
|
|
conversationId: string,
|
|
state: ResumableStreamState | null,
|
|
fallbackModel: string | null
|
|
): string {
|
|
const model = state && state.model !== undefined ? state.model : fallbackModel;
|
|
|
|
return streamIdentity(conversationId, model);
|
|
}
|
|
|
|
/**
|
|
* Reconnect to an interrupted stream for this conversation. Returns the fetch Response so the
|
|
* existing SSE parser drains it like a fresh stream. The server returns 200 on success, 404 if
|
|
* no session exists for the conv_id, and 400 if the offset is below the dropped prefix.
|
|
*/
|
|
// probe the resume route status without consuming the stream: the SSE route has no HEAD,
|
|
// 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`,
|
|
{
|
|
headers: getAuthHeaders(),
|
|
signal: ac.signal
|
|
}
|
|
);
|
|
|
|
ac.abort();
|
|
|
|
return resp.status;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
static async resumeStream(
|
|
conversationId: string,
|
|
signal?: AbortSignal,
|
|
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, { headers: getAuthHeaders(), method: 'GET', signal });
|
|
}
|
|
|
|
static async preEncode(
|
|
messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
|
|
model?: string | null,
|
|
excludeReasoning?: boolean,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
const normalizedMessages: ApiChatMessageData[] = (
|
|
await Promise.all(
|
|
messages.map((msg) => {
|
|
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
|
|
return ChatService.convertDbMessageToApiChatMessageData(
|
|
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
|
);
|
|
}
|
|
|
|
return msg as ApiChatMessageData;
|
|
})
|
|
)
|
|
).filter((msg: { role: ChatRole; content: string | ApiChatMessageContentPart[] }) => {
|
|
if (msg.role === MessageRole.SYSTEM) {
|
|
const content = typeof msg.content === 'string' ? msg.content : '';
|
|
|
|
return content.trim().length > 0;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
const requestBody: Record<string, unknown> = {
|
|
messages: normalizedMessages.map((msg: ApiChatMessageData) => {
|
|
const mapped: Record<string, unknown> = {
|
|
content: excludeReasoning ? ChatService.stripReasoningContent(msg.content) : msg.content,
|
|
role: msg.role,
|
|
tool_call_id: msg.tool_call_id,
|
|
tool_calls: msg.tool_calls
|
|
};
|
|
|
|
if (!excludeReasoning && msg.reasoning_content) {
|
|
mapped.reasoning_content = msg.reasoning_content;
|
|
}
|
|
|
|
return mapped;
|
|
}),
|
|
n_predict: 0,
|
|
stream: false
|
|
};
|
|
|
|
if (model) {
|
|
requestBody.model = model;
|
|
}
|
|
|
|
try {
|
|
await fetch(API_CHAT.COMPLETIONS, {
|
|
body: JSON.stringify(requestBody),
|
|
headers: getJsonHeaders(),
|
|
method: 'POST',
|
|
signal
|
|
});
|
|
} catch (error) {
|
|
if (!isAbortError(error)) {
|
|
console.warn('[ChatService] Pre-encode request failed:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* Streaming
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Handles streaming response from the chat completion API
|
|
* @param response - The Response object from the fetch request
|
|
* @param onChunk - Optional callback invoked for each content chunk received
|
|
* @param onComplete - Optional callback invoked when the stream is complete with full response
|
|
* @param onError - Optional callback invoked if an error occurs during streaming
|
|
* @param onReasoningChunk - Optional callback invoked for each reasoning content chunk
|
|
* @param conversationId - Optional conversation ID for per-conversation state tracking
|
|
* @returns {Promise<void>} Promise that resolves when streaming is complete
|
|
* @throws {Error} if the stream cannot be read or parsed
|
|
*/
|
|
static async handleStreamResponse(
|
|
response: Response,
|
|
onChunk?: (chunk: string) => void,
|
|
onComplete?: (
|
|
response: string,
|
|
reasoningContent?: string,
|
|
timings?: ChatMessageTimings,
|
|
toolCalls?: string
|
|
) => void,
|
|
onError?: (error: Error) => void,
|
|
onReasoningChunk?: (chunk: string) => void,
|
|
onToolCallChunk?: (chunk: string) => void,
|
|
onModel?: (model: string) => void,
|
|
onCompletionId?: (id: string) => void,
|
|
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
|
|
conversationId?: string,
|
|
abortSignal?: AbortSignal,
|
|
onConnectionState?: (state: StreamConnectionState) => void,
|
|
streamModel?: string | null
|
|
): Promise<void> {
|
|
let reader = response.body?.getReader();
|
|
|
|
if (!reader) {
|
|
throw new Error('No response body');
|
|
}
|
|
|
|
// bytesParsed is the absolute server side buffer offset of the next byte to parse
|
|
// segmentStartOffset is the absolute offset where the current reader started, reset on resume
|
|
// segmentBytesRead is wire bytes read by the current reader
|
|
let bytesParsed = 0;
|
|
let segmentStartOffset = 0;
|
|
let segmentBytesRead = 0;
|
|
let lastByteAt = Date.now();
|
|
// each resume must produce at least one byte to be retried again
|
|
// 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();
|
|
let aggregatedContent = '';
|
|
let fullReasoningContent = '';
|
|
let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
|
|
let lastTimings: ChatMessageTimings | undefined;
|
|
let streamFinished = false;
|
|
let modelEmitted = false;
|
|
let idEmitted = false;
|
|
let toolCallIndexOffset = 0;
|
|
let hasOpenToolCallBatch = false;
|
|
|
|
const finalizeOpenToolCallBatch = () => {
|
|
if (!hasOpenToolCallBatch) {
|
|
return;
|
|
}
|
|
|
|
toolCallIndexOffset = aggregatedToolCalls.length;
|
|
hasOpenToolCallBatch = false;
|
|
};
|
|
const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
|
|
if (!toolCalls || toolCalls.length === 0) {
|
|
return;
|
|
}
|
|
|
|
aggregatedToolCalls = ChatService.mergeToolCallDeltas(
|
|
aggregatedToolCalls,
|
|
toolCalls,
|
|
toolCallIndexOffset
|
|
);
|
|
|
|
if (aggregatedToolCalls.length === 0) {
|
|
return;
|
|
}
|
|
|
|
hasOpenToolCallBatch = true;
|
|
|
|
const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
|
|
|
|
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
|
console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
|
|
}
|
|
|
|
if (!serializedToolCalls) {
|
|
return;
|
|
}
|
|
|
|
if (!abortSignal?.aborted) {
|
|
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) {
|
|
if (abortSignal?.aborted) break;
|
|
|
|
let done: boolean;
|
|
let value: Uint8Array | undefined;
|
|
|
|
try {
|
|
const r = await reader.read();
|
|
|
|
done = r.done;
|
|
value = r.value;
|
|
} catch (readErr) {
|
|
// reader.read() rejects with TypeError when the underlying connection drops
|
|
// instead of just resolving with done=true. treat it like done so the outer
|
|
// loop swaps reader via the resume path
|
|
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;
|
|
|
|
if (value && value.byteLength > 0) {
|
|
segmentBytesRead += value.byteLength;
|
|
lastByteAt = Date.now();
|
|
|
|
if (!madeProgress) {
|
|
madeProgress = true;
|
|
onConnectionState?.(StreamConnectionState.STREAMING);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
for (const line of lines) {
|
|
if (abortSignal?.aborted) break;
|
|
|
|
if (line.startsWith(SSE_DATA_PREFIX)) {
|
|
const data = line.slice(SSE_DATA_PREFIX.length).trim();
|
|
|
|
if (data === SSE_DONE_MARKER) {
|
|
streamFinished = true;
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
|
|
const choice = parsed.choices?.[0];
|
|
const content = choice?.delta?.content;
|
|
const reasoningContent = choice?.delta?.reasoning_content;
|
|
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);
|
|
}
|
|
|
|
if (parsed.id && !idEmitted) {
|
|
idEmitted = true;
|
|
onCompletionId?.(parsed.id);
|
|
}
|
|
|
|
if (promptProgress) {
|
|
ChatService.notifyTimings(undefined, promptProgress, onTimings);
|
|
}
|
|
|
|
if (timings) {
|
|
ChatService.notifyTimings(timings, promptProgress, onTimings);
|
|
lastTimings = timings;
|
|
}
|
|
|
|
if (content) {
|
|
finalizeOpenToolCallBatch();
|
|
aggregatedContent += content;
|
|
|
|
if (!abortSignal?.aborted) {
|
|
onChunk?.(content);
|
|
}
|
|
}
|
|
|
|
if (reasoningContent) {
|
|
finalizeOpenToolCallBatch();
|
|
fullReasoningContent += reasoningContent;
|
|
|
|
if (!abortSignal?.aborted) {
|
|
onReasoningChunk?.(reasoningContent);
|
|
}
|
|
}
|
|
|
|
processToolCallDelta(toolCalls);
|
|
} catch (e) {
|
|
console.error('Error parsing JSON chunk:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
onConnectionState?.(StreamConnectionState.RESUMING);
|
|
madeProgress = false;
|
|
|
|
// the server resends starting at bytesParsed, discard any partial line we held, it
|
|
// will be retransmitted from a clean line boundary. reuse the frozen model, not the
|
|
// live dropdown
|
|
const resumeResp = await ChatService.resumeStream(
|
|
conversationId,
|
|
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 {
|
|
reader.releaseLock();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
reader = newReader;
|
|
decoder = new TextDecoder();
|
|
chunk = '';
|
|
segmentStartOffset = bytesParsed;
|
|
segmentBytesRead = 0;
|
|
lastByteAt = Date.now();
|
|
}
|
|
|
|
if (abortSignal?.aborted) return;
|
|
|
|
if (streamFinished) {
|
|
finalizeOpenToolCallBatch();
|
|
|
|
if (conversationId) {
|
|
ChatService.clearStreamState(conversationId);
|
|
}
|
|
|
|
const finalToolCalls =
|
|
aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined;
|
|
|
|
onComplete?.(
|
|
aggregatedContent,
|
|
fullReasoningContent || undefined,
|
|
lastTimings,
|
|
finalToolCalls
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const err = error instanceof Error ? error : new Error('Stream error');
|
|
|
|
onError?.(err);
|
|
|
|
throw err;
|
|
} finally {
|
|
if (typeof document !== 'undefined') {
|
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
}
|
|
|
|
try {
|
|
reader.releaseLock();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles non-streaming response from the chat completion API.
|
|
* Parses the JSON response and extracts the generated content.
|
|
*
|
|
* @param response - The fetch Response object containing the JSON data
|
|
* @param onComplete - Optional callback invoked when response is successfully parsed
|
|
* @param onError - Optional callback invoked if an error occurs while parsing
|
|
* @returns {Promise<string>} Promise that resolves to the generated content string
|
|
* @throws {Error} if the response cannot be parsed or is malformed
|
|
*/
|
|
private static async handleNonStreamResponse(
|
|
response: Response,
|
|
onComplete?: (
|
|
response: string,
|
|
reasoningContent?: string,
|
|
timings?: ChatMessageTimings,
|
|
toolCalls?: string
|
|
) => void,
|
|
onError?: (error: Error) => void,
|
|
onToolCallChunk?: (chunk: string) => void,
|
|
onModel?: (model: string) => void
|
|
): Promise<string> {
|
|
try {
|
|
const responseText = await response.text();
|
|
|
|
if (!responseText.trim()) {
|
|
const noResponseError = new Error('No response received from server. Please try again.');
|
|
|
|
throw noResponseError;
|
|
}
|
|
|
|
const data: ApiChatCompletionResponse = JSON.parse(responseText);
|
|
const responseModel = ChatService.extractModelName(data);
|
|
|
|
if (responseModel) {
|
|
onModel?.(responseModel);
|
|
}
|
|
|
|
const content = data.choices[0]?.message?.content || '';
|
|
const reasoningContent = data.choices[0]?.message?.reasoning_content;
|
|
const toolCalls = data.choices[0]?.message?.tool_calls;
|
|
|
|
let serializedToolCalls: string | undefined;
|
|
|
|
if (toolCalls && toolCalls.length > 0) {
|
|
const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls);
|
|
|
|
if (mergedToolCalls.length > 0) {
|
|
serializedToolCalls = JSON.stringify(mergedToolCalls);
|
|
|
|
if (serializedToolCalls) {
|
|
onToolCallChunk?.(serializedToolCalls);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!content.trim() && !serializedToolCalls) {
|
|
const noResponseError = new Error('No response received from server. Please try again.');
|
|
|
|
throw noResponseError;
|
|
}
|
|
|
|
onComplete?.(content, reasoningContent, undefined, serializedToolCalls);
|
|
|
|
return content;
|
|
} catch (error) {
|
|
const err = error instanceof Error ? error : new Error('Parse error');
|
|
|
|
onError?.(err);
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merges tool call deltas into an existing array of tool calls.
|
|
* Handles both existing and new tool calls, updating existing ones and adding new ones.
|
|
*
|
|
* @param existing - The existing array of tool calls to merge into
|
|
* @param deltas - The array of tool call deltas to merge
|
|
* @param indexOffset - Optional offset to apply to the index of new tool calls
|
|
* @returns {ApiChatCompletionToolCall[]} The merged array of tool calls
|
|
*/
|
|
private static mergeToolCallDeltas(
|
|
existing: ApiChatCompletionToolCall[],
|
|
deltas: ApiChatCompletionToolCallDelta[],
|
|
indexOffset = 0
|
|
): ApiChatCompletionToolCall[] {
|
|
const result = existing.map((call) => ({
|
|
...call,
|
|
function: call.function ? { ...call.function } : undefined
|
|
}));
|
|
|
|
for (const delta of deltas) {
|
|
const index =
|
|
typeof delta.index === 'number' && delta.index >= 0
|
|
? delta.index + indexOffset
|
|
: result.length;
|
|
|
|
while (result.length <= index) {
|
|
result.push({ function: undefined });
|
|
}
|
|
|
|
const target = result[index]!;
|
|
|
|
if (delta.id) {
|
|
target.id = delta.id;
|
|
}
|
|
|
|
if (delta.type) {
|
|
target.type = delta.type;
|
|
}
|
|
|
|
if (delta.function) {
|
|
const fn = target.function ? { ...target.function } : {};
|
|
|
|
if (delta.function.name) {
|
|
fn.name = delta.function.name;
|
|
}
|
|
|
|
if (delta.function.arguments) {
|
|
fn.arguments = (fn.arguments ?? '') + delta.function.arguments;
|
|
}
|
|
|
|
target.function = fn;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* Conversion
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Converts a database message with attachments to API chat message format.
|
|
* Processes various attachment types (images, text files, PDFs) and formats them
|
|
* as content parts suitable for the chat completion API.
|
|
*
|
|
* @param message - Database message object with optional extra attachments
|
|
* @param message.content - The text content of the message
|
|
* @param message.role - The role of the message sender (user, assistant, system)
|
|
* @param message.extra - Optional array of message attachments (images, files, etc.)
|
|
* @returns {ApiChatMessageData} object formatted for the chat completion API
|
|
* @static
|
|
*/
|
|
static async convertDbMessageToApiChatMessageData(
|
|
message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
|
): Promise<ApiChatMessageData> {
|
|
// Handle tool result messages (role: 'tool')
|
|
if (message.role === MessageRole.TOOL && message.toolCallId) {
|
|
return {
|
|
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);
|
|
} catch {
|
|
// Ignore parse errors for malformed tool calls
|
|
}
|
|
}
|
|
|
|
if (!message.extra || message.extra.length === 0) {
|
|
const result: ApiChatMessageData = {
|
|
content: message.content,
|
|
role: message.role as MessageRole
|
|
};
|
|
|
|
if (message.reasoningContent) {
|
|
result.reasoning_content = message.reasoningContent;
|
|
}
|
|
|
|
if (toolCalls && toolCalls.length > 0) {
|
|
result.tool_calls = toolCalls;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const contentParts: ApiChatMessageContentPart[] = [];
|
|
const textFiles = message.extra.filter(
|
|
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
|
|
extra.type === AttachmentType.TEXT
|
|
);
|
|
|
|
for (const textFile of textFiles) {
|
|
contentParts.push({
|
|
text: formatAttachmentText('File', textFile.name, textFile.content),
|
|
type: ContentPartType.TEXT
|
|
});
|
|
}
|
|
|
|
// Handle legacy 'context' type from the old UI (pasted content)
|
|
const legacyContextFiles = message.extra.filter(
|
|
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
|
|
extra.type === AttachmentType.LEGACY_CONTEXT
|
|
);
|
|
|
|
for (const legacyContextFile of legacyContextFiles) {
|
|
contentParts.push({
|
|
text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content),
|
|
type: ContentPartType.TEXT
|
|
});
|
|
}
|
|
|
|
const imageFiles = message.extra.filter(
|
|
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
|
|
extra.type === AttachmentType.IMAGE
|
|
);
|
|
|
|
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({
|
|
image_url: { url: base64Url },
|
|
type: ContentPartType.IMAGE_URL
|
|
});
|
|
}
|
|
|
|
const audioFiles = message.extra.filter(
|
|
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
|
|
extra.type === AttachmentType.AUDIO
|
|
);
|
|
|
|
for (const audio of audioFiles) {
|
|
contentParts.push({
|
|
input_audio: {
|
|
data: audio.base64Data,
|
|
format: getAudioInputFormat(audio.mimeType)
|
|
},
|
|
type: ContentPartType.INPUT_AUDIO
|
|
});
|
|
}
|
|
|
|
if (message.content) {
|
|
contentParts.push({
|
|
text: message.content,
|
|
type: ContentPartType.TEXT
|
|
});
|
|
}
|
|
|
|
const videoFiles = message.extra.filter(
|
|
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraVideoFile =>
|
|
extra.type === AttachmentType.VIDEO
|
|
);
|
|
|
|
for (const video of videoFiles) {
|
|
contentParts.push({
|
|
input_video: {
|
|
data: video.base64Data,
|
|
format: video.mimeType.includes('mp4')
|
|
? 'mp4'
|
|
: video.mimeType.includes('ogg')
|
|
? 'ogg'
|
|
: 'auto'
|
|
},
|
|
type: ContentPartType.INPUT_VIDEO
|
|
});
|
|
}
|
|
|
|
const pdfFiles = message.extra.filter(
|
|
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
|
|
extra.type === AttachmentType.PDF
|
|
);
|
|
|
|
for (const pdfFile of pdfFiles) {
|
|
if (pdfFile.processedAsImages && pdfFile.images) {
|
|
for (let i = 0; i < pdfFile.images.length; i++) {
|
|
contentParts.push({
|
|
image_url: { url: pdfFile.images[i] },
|
|
type: ContentPartType.IMAGE_URL
|
|
});
|
|
}
|
|
} else {
|
|
contentParts.push({
|
|
text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content),
|
|
type: ContentPartType.TEXT
|
|
});
|
|
}
|
|
}
|
|
|
|
const mcpPrompts = message.extra.filter(
|
|
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpPrompt =>
|
|
extra.type === AttachmentType.MCP_PROMPT
|
|
);
|
|
|
|
for (const mcpPrompt of mcpPrompts) {
|
|
contentParts.push({
|
|
text: formatAttachmentText(
|
|
ATTACHMENT_LABEL_MCP_PROMPT,
|
|
mcpPrompt.name,
|
|
mcpPrompt.content,
|
|
mcpPrompt.serverName
|
|
),
|
|
type: ContentPartType.TEXT
|
|
});
|
|
}
|
|
|
|
const mcpResources = message.extra.filter(
|
|
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpResource =>
|
|
extra.type === AttachmentType.MCP_RESOURCE
|
|
);
|
|
|
|
for (const mcpResource of mcpResources) {
|
|
contentParts.push({
|
|
text: formatAttachmentText(
|
|
ATTACHMENT_LABEL_MCP_RESOURCE,
|
|
mcpResource.name,
|
|
mcpResource.content,
|
|
mcpResource.serverName
|
|
),
|
|
type: ContentPartType.TEXT
|
|
});
|
|
}
|
|
|
|
const result: ApiChatMessageData = {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* Utilities
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Strips legacy inline reasoning content tags from message content.
|
|
* Handles both plain string content and multipart content arrays.
|
|
*/
|
|
private static stripReasoningContent(
|
|
content: string | ApiChatMessageContentPart[]
|
|
): string | ApiChatMessageContentPart[] {
|
|
const stripFromString = (text: string): string =>
|
|
text.replace(LEGACY_AGENTIC_REGEX.REASONING_BLOCK, '').trim();
|
|
|
|
if (typeof content === 'string') {
|
|
return stripFromString(content);
|
|
}
|
|
|
|
return content.map((part) => {
|
|
if (part.type === ContentPartType.TEXT && part.text) {
|
|
return { ...part, text: stripFromString(part.text) };
|
|
}
|
|
|
|
return part;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parses error response and creates appropriate error with context information
|
|
* @param response - HTTP response object
|
|
* @returns Promise<Error> - Parsed error with context info if available
|
|
*/
|
|
private static async parseErrorResponse(
|
|
response: Response
|
|
): Promise<Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }> {
|
|
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_ctx: errorData.error.n_ctx,
|
|
n_prompt_tokens: errorData.error.n_prompt_tokens
|
|
};
|
|
}
|
|
|
|
return error;
|
|
} catch {
|
|
const fallback = new Error(
|
|
`Server error (${response.status}): ${response.statusText}`
|
|
) as Error & {
|
|
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
|
|
};
|
|
|
|
fallback.name = 'HttpError';
|
|
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts model name from Chat Completions API response data.
|
|
* Handles various response formats including streaming chunks and final responses.
|
|
*
|
|
* WORKAROUND: In single model mode, llama-server returns a default/incorrect model name
|
|
* in the response. We override it with the actual model name from serverStore.
|
|
*
|
|
* @param data - Raw response data from the Chat Completions API
|
|
* @returns Model name string if found, undefined otherwise
|
|
* @private
|
|
*/
|
|
private static extractModelName(data: unknown): string | undefined {
|
|
const asRecord = (value: unknown): Record<string, unknown> | undefined => {
|
|
return typeof value === 'object' && value !== null
|
|
? (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;
|
|
}
|
|
|
|
// avoid guessing from non-standard locations (metadata, etc.)
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Calls the onTimings callback with timing data from streaming response.
|
|
*
|
|
* @param timings - Timing information from the Chat Completions API response
|
|
* @param promptProgress - Prompt processing progress data
|
|
* @param onTimingsCallback - Callback function to invoke with timing data
|
|
* @private
|
|
*/
|
|
private static notifyTimings(
|
|
timings: ChatMessageTimings | undefined,
|
|
promptProgress: ChatMessagePromptProgress | undefined,
|
|
onTimingsCallback:
|
|
| ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void)
|
|
| undefined
|
|
): void {
|
|
if (!onTimingsCallback || (!timings && !promptProgress)) return;
|
|
|
|
onTimingsCallback(timings, promptProgress);
|
|
}
|
|
}
|