ui: Restructure repo to use tools/ui folder and ui / UI / llama-ui / LLAMA_UI naming (#23064)
* webui: Move static build output from `tools/server/public` to `build/ui` directory * refactor: Move to `tools/ui` * refactor: rename CMake variables and preprocessor defines - Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated) - Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated) - Backward compat: old vars auto-forward to new ones with DEPRECATION warning - Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc. - Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET - Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines - Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED * refactor: rename CLI flags (--webui -> --ui) with backward compat - Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases) - Add --ui-config (old --webui-config kept as deprecated alias) - Add --ui-config-file (old --webui-config-file kept as deprecated alias) - Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated) - Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY - C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields - Backward compat: old fields synced to new ones in g_params_to_internals * refactor: update C++ server internals with backward compat - Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta) - Rename params.webui usage -> params.ui (both synced, old still works) - JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys - Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy - Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) * refactor: rename CI/CD workflows, artifacts, and build script - Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build - Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT - Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks - Update server.yml: job/artifact refs webui-build -> ui-build - Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT - Update server-self-hosted.yml: webui-build -> ui-build - Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION - Rename webui-download.cmake -> ui-download.cmake (internal refs updated) - Update labeler.yml: server/webui -> server/ui path label * docs: update CODEOWNERS and server README docs - Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/ - Update server README.md: CLI tables show --ui flags with deprecated --webui aliases - Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/ * fix: Small fixes for UI build * fix: CMake.txt syntax * chore: Formatting * fix: `.editorconfig` for llama-ui * chore: Formatting * refactor: Use `APP_NAME` in Error route * refactor: Cleanup * refactor: Single migration service * make llama-ui a linkable target * fix: UI Build output * fix: Missing change * fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI * refactor: UI workflows cleanup --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
co-authored by
Xuan Son Nguyen
parent
49d1701bd2
commit
59778f0196
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Abort Signal Utilities
|
||||
*
|
||||
* Provides utilities for consistent AbortSignal propagation across the application.
|
||||
* These utilities help ensure that async operations can be properly cancelled
|
||||
* when needed (e.g., user stops generation, navigates away, etc.).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Throws an AbortError if the signal is aborted.
|
||||
* Use this at the start of async operations to fail fast.
|
||||
*
|
||||
* @param signal - Optional AbortSignal to check
|
||||
* @throws DOMException with name 'AbortError' if signal is aborted
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* async function fetchData(signal?: AbortSignal) {
|
||||
* throwIfAborted(signal);
|
||||
* // ... proceed with operation
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Operation was aborted', 'AbortError');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error is an AbortError.
|
||||
* Use this to distinguish between user-initiated cancellation and actual errors.
|
||||
*
|
||||
* @param error - Error to check
|
||||
* @returns true if the error is an AbortError
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* try {
|
||||
* await fetchData(signal);
|
||||
* } catch (error) {
|
||||
* if (isAbortError(error)) {
|
||||
* // User cancelled - no error dialog needed
|
||||
* return;
|
||||
* }
|
||||
* // Handle actual error
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new AbortController that is linked to one or more parent signals.
|
||||
* When any parent signal aborts, the returned controller also aborts.
|
||||
*
|
||||
* Useful for creating child operations that should be cancelled when
|
||||
* either the parent operation or their own timeout/condition triggers.
|
||||
*
|
||||
* @param signals - Parent signals to link to (undefined signals are ignored)
|
||||
* @returns A new AbortController linked to all provided signals
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Link to user's abort signal and add a timeout
|
||||
* const linked = createLinkedController(userSignal, timeoutSignal);
|
||||
* await fetch(url, { signal: linked.signal });
|
||||
* ```
|
||||
*/
|
||||
export function createLinkedController(...signals: (AbortSignal | undefined)[]): AbortController {
|
||||
const controller = new AbortController();
|
||||
|
||||
for (const signal of signals) {
|
||||
if (!signal) continue;
|
||||
|
||||
// If already aborted, abort immediately
|
||||
if (signal.aborted) {
|
||||
controller.abort(signal.reason);
|
||||
return controller;
|
||||
}
|
||||
|
||||
// Link to parent signal
|
||||
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
|
||||
}
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AbortSignal that times out after the specified duration.
|
||||
*
|
||||
* @param ms - Timeout duration in milliseconds
|
||||
* @returns AbortSignal that will abort after the timeout
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const signal = createTimeoutSignal(5000); // 5 second timeout
|
||||
* await fetch(url, { signal });
|
||||
* ```
|
||||
*/
|
||||
export function createTimeoutSignal(ms: number): AbortSignal {
|
||||
return AbortSignal.timeout(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a promise to reject if the signal is aborted.
|
||||
* Useful for making non-abortable promises respect an AbortSignal.
|
||||
*
|
||||
* @param promise - Promise to wrap
|
||||
* @param signal - AbortSignal to respect
|
||||
* @returns Promise that rejects with AbortError if signal aborts
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Make a non-abortable operation respect abort signal
|
||||
* const result = await withAbortSignal(
|
||||
* someNonAbortableOperation(),
|
||||
* signal
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function withAbortSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
|
||||
throwIfAborted(signal);
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abortHandler = () => {
|
||||
reject(new DOMException('Operation was aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
promise
|
||||
.then((value) => {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { AgenticSectionType, MessageRole } from '$lib/enums';
|
||||
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
|
||||
import type { ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
import type {
|
||||
DatabaseMessage,
|
||||
DatabaseMessageExtra,
|
||||
DatabaseMessageExtraImageFile
|
||||
} from '$lib/types/database';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Represents a parsed section of agentic content for display
|
||||
*/
|
||||
export interface AgenticSection {
|
||||
type: AgenticSectionType;
|
||||
content: string;
|
||||
toolName?: string;
|
||||
toolArgs?: string;
|
||||
toolResult?: string;
|
||||
toolResultExtras?: DatabaseMessageExtra[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a tool result line that may reference an image attachment
|
||||
*/
|
||||
export type ToolResultLine = {
|
||||
text: string;
|
||||
image?: DatabaseMessageExtraImageFile;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives display sections from a single assistant message and its direct tool results.
|
||||
*
|
||||
* @param message - The assistant message
|
||||
* @param toolMessages - Tool result messages for this assistant's tool_calls
|
||||
* @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
|
||||
*/
|
||||
function deriveSingleTurnSections(
|
||||
message: DatabaseMessage,
|
||||
toolMessages: DatabaseMessage[] = [],
|
||||
streamingToolCalls: ApiChatCompletionToolCall[] = [],
|
||||
isStreaming: boolean = false
|
||||
): AgenticSection[] {
|
||||
const sections: AgenticSection[] = [];
|
||||
|
||||
// 1. Reasoning content (from dedicated field)
|
||||
if (message.reasoningContent) {
|
||||
const toolCalls = parseToolCalls(message.toolCalls);
|
||||
const hasContentAfterReasoning =
|
||||
!!message.content?.trim() || toolCalls.length > 0 || streamingToolCalls.length > 0;
|
||||
const isPending = isStreaming && !hasContentAfterReasoning;
|
||||
sections.push({
|
||||
type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
|
||||
content: message.reasoningContent
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Text content
|
||||
if (message.content?.trim()) {
|
||||
sections.push({
|
||||
type: AgenticSectionType.TEXT,
|
||||
content: message.content
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Persisted tool calls (from message.toolCalls field)
|
||||
const toolCalls = parseToolCalls(message.toolCalls);
|
||||
for (const tc of toolCalls) {
|
||||
const resultMsg = toolMessages.find((m) => m.toolCallId === tc.id);
|
||||
// Only show as pending/loading if we're actively streaming; otherwise it's just a tool call without result
|
||||
const type = resultMsg
|
||||
? AgenticSectionType.TOOL_CALL
|
||||
: isStreaming
|
||||
? AgenticSectionType.TOOL_CALL_PENDING
|
||||
: AgenticSectionType.TOOL_CALL;
|
||||
sections.push({
|
||||
type,
|
||||
content: resultMsg?.content || '',
|
||||
toolName: tc.function?.name,
|
||||
toolArgs: tc.function?.arguments,
|
||||
toolResult: resultMsg?.content,
|
||||
toolResultExtras: resultMsg?.extra
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Streaming tool calls (not yet persisted - currently being received)
|
||||
for (const tc of streamingToolCalls) {
|
||||
// Skip if already in persisted tool calls
|
||||
if (tc.id && toolCalls.find((t) => t.id === tc.id)) continue;
|
||||
sections.push({
|
||||
type: AgenticSectionType.TOOL_CALL_STREAMING,
|
||||
content: '',
|
||||
toolName: tc.function?.name,
|
||||
toolArgs: tc.function?.arguments
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives display sections from structured message data.
|
||||
*
|
||||
* Handles both single-turn (one assistant + its tool results) and multi-turn
|
||||
* agentic sessions (multiple assistant + tool messages grouped together).
|
||||
*
|
||||
* When `toolMessages` contains continuation assistant messages (from multi-turn
|
||||
* agentic flows), they are processed in order to produce sections across all turns.
|
||||
*
|
||||
* @param message - The first/anchor assistant message
|
||||
* @param toolMessages - Tool result messages and continuation assistant messages
|
||||
* @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
|
||||
* @param isStreaming - Whether the message is currently being streamed
|
||||
*/
|
||||
export function deriveAgenticSections(
|
||||
message: DatabaseMessage,
|
||||
toolMessages: DatabaseMessage[] = [],
|
||||
streamingToolCalls: ApiChatCompletionToolCall[] = [],
|
||||
isStreaming: boolean = false
|
||||
): AgenticSection[] {
|
||||
const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT);
|
||||
|
||||
if (!hasAssistantContinuations) {
|
||||
return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
|
||||
}
|
||||
|
||||
const sections: AgenticSection[] = [];
|
||||
|
||||
const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
|
||||
sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
|
||||
|
||||
let i = firstTurnToolMsgs.length;
|
||||
|
||||
while (i < toolMessages.length) {
|
||||
const msg = toolMessages[i];
|
||||
|
||||
if (msg.role === MessageRole.ASSISTANT) {
|
||||
const turnToolMsgs = collectToolMessages(toolMessages, i + 1);
|
||||
const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
|
||||
|
||||
sections.push(
|
||||
...deriveSingleTurnSections(
|
||||
msg,
|
||||
turnToolMsgs,
|
||||
isLastTurn ? streamingToolCalls : [],
|
||||
isLastTurn && isStreaming
|
||||
)
|
||||
);
|
||||
|
||||
i += 1 + turnToolMsgs.length;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect consecutive tool messages starting at `startIndex`.
|
||||
*/
|
||||
function collectToolMessages(messages: DatabaseMessage[], startIndex: number): DatabaseMessage[] {
|
||||
const result: DatabaseMessage[] = [];
|
||||
|
||||
for (let i = startIndex; i < messages.length; i++) {
|
||||
if (messages[i].role === MessageRole.TOOL) {
|
||||
result.push(messages[i]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool result text into lines, matching image attachments by name.
|
||||
*/
|
||||
export function parseToolResultWithImages(
|
||||
toolResult: string,
|
||||
extras?: DatabaseMessageExtra[]
|
||||
): ToolResultLine[] {
|
||||
const lines = toolResult.split(NEWLINE_SEPARATOR);
|
||||
return lines.map((line) => {
|
||||
const match = line.match(ATTACHMENT_SAVED_REGEX);
|
||||
if (!match || !extras) return { text: line };
|
||||
|
||||
const attachmentName = match[1];
|
||||
const image = extras.find(
|
||||
(e): e is DatabaseMessageExtraImageFile =>
|
||||
e.type === AttachmentType.IMAGE && e.name === attachmentName
|
||||
);
|
||||
|
||||
return { text: line, image };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse the toolCalls JSON string from a DatabaseMessage.
|
||||
*/
|
||||
function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
|
||||
if (!toolCallsJson) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(toolCallsJson);
|
||||
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has agentic content (tool calls or is part of an agentic flow).
|
||||
*/
|
||||
export function hasAgenticContent(
|
||||
message: DatabaseMessage,
|
||||
toolMessages: DatabaseMessage[] = []
|
||||
): boolean {
|
||||
if (message.toolCalls) {
|
||||
const tc = parseToolCalls(message.toolCalls);
|
||||
|
||||
if (tc.length > 0) return true;
|
||||
}
|
||||
|
||||
return toolMessages.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { base } from '$app/paths';
|
||||
import { getJsonHeaders, getAuthHeaders } from './api-headers';
|
||||
import { UrlProtocol } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* API Fetch Utilities
|
||||
*
|
||||
* Provides common fetch patterns used across services:
|
||||
* - Automatic JSON headers
|
||||
* - Error handling with proper error messages
|
||||
* - Base path resolution
|
||||
*/
|
||||
|
||||
export interface ApiFetchOptions extends Omit<RequestInit, 'headers'> {
|
||||
/**
|
||||
* Use auth-only headers (no Content-Type).
|
||||
* Default: false (uses JSON headers with Content-Type: application/json)
|
||||
*/
|
||||
authOnly?: boolean;
|
||||
/**
|
||||
* Additional headers to merge with default headers.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON data from an API endpoint with standard headers and error handling.
|
||||
*
|
||||
* @param path - API path (will be prefixed with base path)
|
||||
* @param options - Fetch options with additional authOnly flag
|
||||
* @returns Parsed JSON response
|
||||
* @throws Error with formatted message on failure
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // GET request
|
||||
* const models = await apiFetch<ApiModelListResponse>('/v1/models');
|
||||
*
|
||||
* // POST request
|
||||
* const result = await apiFetch<ApiResponse>('/models/load', {
|
||||
* method: 'POST',
|
||||
* body: JSON.stringify({ model: 'gpt-4' })
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
|
||||
|
||||
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
|
||||
const headers = { ...baseHeaders, ...customHeaders };
|
||||
|
||||
const url =
|
||||
path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS)
|
||||
? path
|
||||
: `${base}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await parseErrorMessage(response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with URL constructed from base URL and query parameters.
|
||||
*
|
||||
* @param basePath - Base API path
|
||||
* @param params - Query parameters to append
|
||||
* @param options - Fetch options
|
||||
* @returns Parsed JSON response
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const props = await apiFetchWithParams<ApiProps>('./props', {
|
||||
* model: 'gpt-4',
|
||||
* autoload: 'false'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function apiFetchWithParams<T>(
|
||||
basePath: string,
|
||||
params: Record<string, string>,
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<T> {
|
||||
const url = new URL(basePath, window.location.href);
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
|
||||
|
||||
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
|
||||
const headers = { ...baseHeaders, ...customHeaders };
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
...fetchOptions,
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await parseErrorMessage(response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON data to an API endpoint.
|
||||
*
|
||||
* @param path - API path
|
||||
* @param body - Request body (will be JSON stringified)
|
||||
* @param options - Additional fetch options
|
||||
* @returns Parsed JSON response
|
||||
*/
|
||||
export async function apiPost<T, B = unknown>(
|
||||
path: string,
|
||||
body: B,
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<T> {
|
||||
return apiFetch<T>(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error message from a failed response.
|
||||
* Tries to extract error message from JSON body, falls back to status text.
|
||||
*/
|
||||
async function parseErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData?.error?.message) {
|
||||
return errorData.error.message;
|
||||
}
|
||||
if (errorData?.error && typeof errorData.error === 'string') {
|
||||
return errorData.error;
|
||||
}
|
||||
if (errorData?.message) {
|
||||
return errorData.message;
|
||||
}
|
||||
} catch {
|
||||
// JSON parsing failed, use status text
|
||||
}
|
||||
|
||||
return `Request failed: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { REDACTED_HEADERS } from '$lib/constants';
|
||||
import { redactValue } from './redact';
|
||||
|
||||
/**
|
||||
* Get authorization headers for API requests
|
||||
* Includes Bearer token if API key is configured
|
||||
*/
|
||||
export function getAuthHeaders(): Record<string, string> {
|
||||
const currentConfig = config();
|
||||
const apiKey = currentConfig.apiKey?.toString().trim();
|
||||
|
||||
return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get standard JSON headers with optional authorization
|
||||
*/
|
||||
export function getJsonHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTTP headers by redacting sensitive values.
|
||||
* Known sensitive headers (from REDACTED_HEADERS) and any extra headers
|
||||
* specified by the caller are fully redacted. Headers listed in
|
||||
* `partialRedactHeaders` are partially redacted, showing only the
|
||||
* specified number of trailing characters.
|
||||
*
|
||||
* @param headers - Headers to sanitize
|
||||
* @param extraRedactedHeaders - Additional header names to fully redact
|
||||
* @param partialRedactHeaders - Map of header name -> number of trailing chars to keep visible
|
||||
* @returns Object with header names as keys and (possibly redacted) values
|
||||
*/
|
||||
export function sanitizeHeaders(
|
||||
headers?: HeadersInit,
|
||||
extraRedactedHeaders?: Iterable<string>,
|
||||
partialRedactHeaders?: Map<string, number>
|
||||
): Record<string, string> {
|
||||
if (!headers) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized = new Headers(headers);
|
||||
const sanitized: Record<string, string> = {};
|
||||
const redactedHeaders = new Set(
|
||||
Array.from(extraRedactedHeaders ?? [], (header) => header.toLowerCase())
|
||||
);
|
||||
|
||||
for (const [key, value] of normalized.entries()) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const partialChars = partialRedactHeaders?.get(normalizedKey);
|
||||
|
||||
if (partialChars !== undefined) {
|
||||
sanitized[key] = redactValue(value, partialChars);
|
||||
} else if (REDACTED_HEADERS.has(normalizedKey) || redactedHeaders.has(normalizedKey)) {
|
||||
sanitized[key] = redactValue(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { base } from '$app/paths';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { browser } from '$app/environment';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
|
||||
/**
|
||||
* Validates API key by making a request to the server props endpoint
|
||||
* Throws SvelteKit errors for authentication failures or server issues
|
||||
*/
|
||||
export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<void> {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = config().apiKey;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${base}/props`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw error(401, 'Access denied');
|
||||
}
|
||||
|
||||
console.warn(`Server responded with status ${response.status} during API key validation`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// If it's already a SvelteKit error, re-throw it
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Network or other errors
|
||||
console.warn('Cannot connect to server for API key validation:', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
|
||||
import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
|
||||
import type {
|
||||
AttachmentDisplayItemsOptions,
|
||||
ChatAttachmentDisplayItem,
|
||||
ChatUploadedFile
|
||||
} from '$lib/types';
|
||||
|
||||
/**
|
||||
* Check if a display item represents an MCP prompt
|
||||
* (either from attachment type or uploaded file with mcpPrompt metadata)
|
||||
*/
|
||||
export function isMcpPrompt(item: ChatAttachmentDisplayItem): boolean {
|
||||
if (item.attachment?.type === AttachmentType.MCP_PROMPT) {
|
||||
return true;
|
||||
}
|
||||
if (item.uploadedFile?.type === SpecialFileType.MCP_PROMPT && item.uploadedFile.mcpPrompt) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a display item represents an MCP resource
|
||||
*/
|
||||
export function isMcpResource(item: ChatAttachmentDisplayItem): boolean {
|
||||
return item.attachment?.type === AttachmentType.MCP_RESOURCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file type category from an uploaded file, checking both MIME type and extension
|
||||
*/
|
||||
function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | null {
|
||||
const categoryByMime = getFileTypeCategory(file.type);
|
||||
|
||||
if (categoryByMime) {
|
||||
return categoryByMime;
|
||||
}
|
||||
|
||||
return getFileTypeCategoryByExtension(file.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unified list of display items from uploaded files and stored attachments.
|
||||
* Items are returned in reverse order (newest first).
|
||||
*/
|
||||
export function getAttachmentDisplayItems(
|
||||
options: AttachmentDisplayItemsOptions
|
||||
): ChatAttachmentDisplayItem[] {
|
||||
const { uploadedFiles = [], attachments = [] } = options;
|
||||
const items: ChatAttachmentDisplayItem[] = [];
|
||||
|
||||
// Add uploaded files (ChatForm)
|
||||
for (const file of uploadedFiles) {
|
||||
items.push({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
preview: file.preview,
|
||||
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
|
||||
isLoading: file.isLoading,
|
||||
loadError: file.loadError,
|
||||
uploadedFile: file,
|
||||
textContent: file.textContent
|
||||
});
|
||||
}
|
||||
|
||||
// Add stored attachments (ChatMessage)
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const isImage = isImageFile(attachment);
|
||||
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
size: 'size' in attachment ? attachment.size : undefined,
|
||||
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
|
||||
isImage,
|
||||
attachment,
|
||||
attachmentIndex: index,
|
||||
textContent: 'content' in attachment ? attachment.content : undefined
|
||||
});
|
||||
}
|
||||
|
||||
return items.reverse();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { AttachmentType, FileTypeCategory } from '$lib/enums';
|
||||
import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils';
|
||||
|
||||
/**
|
||||
* Gets the file type category from an uploaded file, checking both MIME type and extension
|
||||
* @param uploadedFile - The uploaded file to check
|
||||
* @returns The file type category or null if not recognized
|
||||
*/
|
||||
function getUploadedFileCategory(uploadedFile: ChatUploadedFile): FileTypeCategory | null {
|
||||
// First try MIME type
|
||||
const categoryByMime = getFileTypeCategory(uploadedFile.type);
|
||||
|
||||
if (categoryByMime) {
|
||||
return categoryByMime;
|
||||
}
|
||||
|
||||
// Fallback to extension (browsers don't always provide correct MIME types)
|
||||
return getFileTypeCategoryByExtension(uploadedFile.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an attachment or uploaded file is a text file
|
||||
* @param uploadedFile - Optional uploaded file
|
||||
* @param attachment - Optional database attachment
|
||||
* @returns true if the file is a text file
|
||||
*/
|
||||
export function isTextFile(
|
||||
attachment?: DatabaseMessageExtra,
|
||||
uploadedFile?: ChatUploadedFile
|
||||
): boolean {
|
||||
if (uploadedFile) {
|
||||
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT;
|
||||
}
|
||||
|
||||
if (attachment) {
|
||||
return (
|
||||
attachment.type === AttachmentType.TEXT || attachment.type === AttachmentType.LEGACY_CONTEXT
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an attachment or uploaded file is an image
|
||||
* @param uploadedFile - Optional uploaded file
|
||||
* @param attachment - Optional database attachment
|
||||
* @returns true if the file is an image
|
||||
*/
|
||||
export function isImageFile(
|
||||
attachment?: DatabaseMessageExtra,
|
||||
uploadedFile?: ChatUploadedFile
|
||||
): boolean {
|
||||
if (uploadedFile) {
|
||||
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE;
|
||||
}
|
||||
|
||||
if (attachment) {
|
||||
return attachment.type === AttachmentType.IMAGE;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an attachment or uploaded file is a PDF
|
||||
* @param uploadedFile - Optional uploaded file
|
||||
* @param attachment - Optional database attachment
|
||||
* @returns true if the file is a PDF
|
||||
*/
|
||||
export function isPdfFile(
|
||||
attachment?: DatabaseMessageExtra,
|
||||
uploadedFile?: ChatUploadedFile
|
||||
): boolean {
|
||||
if (uploadedFile) {
|
||||
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF;
|
||||
}
|
||||
|
||||
if (attachment) {
|
||||
return attachment.type === AttachmentType.PDF;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an attachment or uploaded file is an audio file
|
||||
* @param uploadedFile - Optional uploaded file
|
||||
* @param attachment - Optional database attachment
|
||||
* @returns true if the file is an audio file
|
||||
*/
|
||||
export function isAudioFile(
|
||||
attachment?: DatabaseMessageExtra,
|
||||
uploadedFile?: ChatUploadedFile
|
||||
): boolean {
|
||||
if (uploadedFile) {
|
||||
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO;
|
||||
}
|
||||
|
||||
if (attachment) {
|
||||
return attachment.type === AttachmentType.AUDIO;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { MimeTypeAudio } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* AudioRecorder - Browser-based audio recording with MediaRecorder API
|
||||
*
|
||||
* This class provides a complete audio recording solution using the browser's MediaRecorder API.
|
||||
* It handles microphone access, recording state management, and audio format optimization.
|
||||
*
|
||||
* **Features:**
|
||||
* - Automatic microphone permission handling
|
||||
* - Audio enhancement (echo cancellation, noise suppression, auto gain)
|
||||
* - Multiple format support with fallback (WAV, WebM, MP4, AAC)
|
||||
* - Real-time recording state tracking
|
||||
* - Proper cleanup and resource management
|
||||
*/
|
||||
export class AudioRecorder {
|
||||
private mediaRecorder: MediaRecorder | null = null;
|
||||
private audioChunks: Blob[] = [];
|
||||
private stream: MediaStream | null = null;
|
||||
private recordingState: boolean = false;
|
||||
|
||||
async startRecording(): Promise<void> {
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
});
|
||||
|
||||
this.initializeRecorder(this.stream);
|
||||
|
||||
this.audioChunks = [];
|
||||
// Start recording with a small timeslice to ensure we get data
|
||||
this.mediaRecorder!.start(100);
|
||||
this.recordingState = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
throw new Error('Failed to access microphone. Please check permissions.');
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording(): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const recorder = this.mediaRecorder;
|
||||
const chunks = this.audioChunks;
|
||||
const stream = this.stream;
|
||||
|
||||
if (!recorder || recorder.state === 'inactive') {
|
||||
reject(new Error('No active recording to stop'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Detach instance state right away so a new startRecording can take over without race
|
||||
this.mediaRecorder = null;
|
||||
this.audioChunks = [];
|
||||
this.stream = null;
|
||||
this.recordingState = false;
|
||||
|
||||
recorder.onstop = () => {
|
||||
const audioBlob = new Blob(chunks, {
|
||||
type: recorder.mimeType || MimeTypeAudio.WAV
|
||||
});
|
||||
|
||||
if (stream) {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
|
||||
resolve(audioBlob);
|
||||
};
|
||||
|
||||
recorder.onerror = (event) => {
|
||||
console.error('Recording error:', event);
|
||||
|
||||
if (stream) {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
|
||||
reject(new Error('Recording failed'));
|
||||
};
|
||||
|
||||
recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
isRecording(): boolean {
|
||||
return this.recordingState;
|
||||
}
|
||||
|
||||
cancelRecording(): void {
|
||||
const recorder = this.mediaRecorder;
|
||||
const stream = this.stream;
|
||||
|
||||
this.mediaRecorder = null;
|
||||
this.audioChunks = [];
|
||||
this.stream = null;
|
||||
this.recordingState = false;
|
||||
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
// Drop the original handlers so the pending stop event does not touch the instance
|
||||
recorder.onstop = null;
|
||||
recorder.onerror = null;
|
||||
recorder.stop();
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private initializeRecorder(stream: MediaStream): void {
|
||||
const options: MediaRecorderOptions = {};
|
||||
|
||||
if (MediaRecorder.isTypeSupported(MimeTypeAudio.WAV)) {
|
||||
options.mimeType = MimeTypeAudio.WAV;
|
||||
} else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM_OPUS)) {
|
||||
options.mimeType = MimeTypeAudio.WEBM_OPUS;
|
||||
} else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM)) {
|
||||
options.mimeType = MimeTypeAudio.WEBM;
|
||||
} else if (MediaRecorder.isTypeSupported(MimeTypeAudio.MP4)) {
|
||||
options.mimeType = MimeTypeAudio.MP4;
|
||||
} else {
|
||||
console.warn('No preferred audio format supported, using default');
|
||||
}
|
||||
|
||||
this.mediaRecorder = new MediaRecorder(stream, options);
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
this.audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = () => {
|
||||
this.recordingState = false;
|
||||
};
|
||||
|
||||
this.mediaRecorder.onerror = (event) => {
|
||||
console.error('MediaRecorder error:', event);
|
||||
this.recordingState = false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertToWav(audioBlob: Blob): Promise<Blob> {
|
||||
try {
|
||||
if (audioBlob.type.includes('wav')) {
|
||||
return audioBlob;
|
||||
}
|
||||
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
|
||||
try {
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
return audioBufferToWav(audioBuffer);
|
||||
} finally {
|
||||
audioContext.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to convert audio to WAV:', error);
|
||||
return audioBlob;
|
||||
}
|
||||
}
|
||||
|
||||
function audioBufferToWav(buffer: AudioBuffer): Blob {
|
||||
const length = buffer.length;
|
||||
const numberOfChannels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const bytesPerSample = 2; // 16-bit
|
||||
const blockAlign = numberOfChannels * bytesPerSample;
|
||||
const byteRate = sampleRate * blockAlign;
|
||||
const dataSize = length * blockAlign;
|
||||
const bufferSize = 44 + dataSize;
|
||||
|
||||
const arrayBuffer = new ArrayBuffer(bufferSize);
|
||||
const view = new DataView(arrayBuffer);
|
||||
|
||||
const writeString = (offset: number, string: string) => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, 'RIFF'); // ChunkID
|
||||
view.setUint32(4, bufferSize - 8, true); // ChunkSize
|
||||
writeString(8, 'WAVE'); // Format
|
||||
writeString(12, 'fmt '); // Subchunk1ID
|
||||
view.setUint32(16, 16, true); // Subchunk1Size
|
||||
view.setUint16(20, 1, true); // AudioFormat (PCM)
|
||||
view.setUint16(22, numberOfChannels, true); // NumChannels
|
||||
view.setUint32(24, sampleRate, true); // SampleRate
|
||||
view.setUint32(28, byteRate, true); // ByteRate
|
||||
view.setUint16(32, blockAlign, true); // BlockAlign
|
||||
view.setUint16(34, 16, true); // BitsPerSample
|
||||
writeString(36, 'data'); // Subchunk2ID
|
||||
view.setUint32(40, dataSize, true); // Subchunk2Size
|
||||
|
||||
// Cache channel arrays, write PCM via Int16Array (native little-endian, matches WAV)
|
||||
const channels: Float32Array[] = new Array(numberOfChannels);
|
||||
for (let c = 0; c < numberOfChannels; c++) {
|
||||
channels[c] = buffer.getChannelData(c);
|
||||
}
|
||||
|
||||
const pcm = new Int16Array(arrayBuffer, 44, length * numberOfChannels);
|
||||
let p = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
for (let c = 0; c < numberOfChannels; c++) {
|
||||
let s = channels[c][i];
|
||||
if (s > 1) s = 1;
|
||||
else if (s < -1) s = -1;
|
||||
pcm[p++] = s * 0x7fff;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([arrayBuffer], { type: MimeTypeAudio.WAV });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a File object from audio blob with timestamp-based naming
|
||||
* @param audioBlob - The audio blob to wrap
|
||||
* @param filename - Optional custom filename
|
||||
* @returns File object with appropriate name and metadata
|
||||
*/
|
||||
export function createAudioFile(audioBlob: Blob, filename?: string): File {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const extension = audioBlob.type.includes('wav') ? 'wav' : 'mp3';
|
||||
const defaultFilename = `recording-${timestamp}.${extension}`;
|
||||
|
||||
return new File([audioBlob], filename || defaultFilename, {
|
||||
type: audioBlob.type,
|
||||
lastModified: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if audio recording is supported in the current browser
|
||||
* @returns True if MediaRecorder and getUserMedia are available
|
||||
*/
|
||||
export function isAudioRecordingSupported(): boolean {
|
||||
return !!(
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === 'function' &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.MediaRecorder
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Automatically resizes a textarea element to fit its content
|
||||
* @param textareaElement - The textarea element to resize
|
||||
*/
|
||||
export default function autoResizeTextarea(textareaElement: HTMLTextAreaElement | null): void {
|
||||
if (textareaElement) {
|
||||
textareaElement.style.height = '1rem';
|
||||
textareaElement.style.height = textareaElement.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Message branching utilities for conversation tree navigation.
|
||||
*
|
||||
* Conversation branching allows users to edit messages and create alternate paths
|
||||
* while preserving the original conversation flow. Each message has parent/children
|
||||
* relationships forming a tree structure.
|
||||
*
|
||||
* Example tree:
|
||||
* root
|
||||
* ├── message 1 (user)
|
||||
* │ └── message 2 (assistant)
|
||||
* │ ├── message 3 (user)
|
||||
* │ └── message 6 (user) ← new branch
|
||||
* └── message 4 (user)
|
||||
* └── message 5 (assistant)
|
||||
*/
|
||||
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Finds a message by its ID in the given messages array.
|
||||
*/
|
||||
export function findMessageById(
|
||||
messages: readonly DatabaseMessage[],
|
||||
id: string | null | undefined
|
||||
): DatabaseMessage | undefined {
|
||||
if (!id) return undefined;
|
||||
return messages.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters messages to get the conversation path from root to a specific leaf node.
|
||||
* If the leafNodeId doesn't exist, returns the path with the latest timestamp.
|
||||
*
|
||||
* @param messages - All messages in the conversation
|
||||
* @param leafNodeId - The target leaf node ID to trace back from
|
||||
* @param includeRoot - Whether to include root messages in the result
|
||||
* @returns Array of messages from root to leaf, sorted by timestamp
|
||||
*/
|
||||
export function filterByLeafNodeId(
|
||||
messages: readonly DatabaseMessage[],
|
||||
leafNodeId: string,
|
||||
includeRoot: boolean = false
|
||||
): readonly DatabaseMessage[] {
|
||||
const result: DatabaseMessage[] = [];
|
||||
const nodeMap = new Map<string, DatabaseMessage>();
|
||||
|
||||
// Build node map for quick lookups
|
||||
for (const msg of messages) {
|
||||
nodeMap.set(msg.id, msg);
|
||||
}
|
||||
|
||||
// Find the starting node (leaf node or latest if not found)
|
||||
let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId);
|
||||
if (!startNode) {
|
||||
// If leaf node not found, use the message with latest timestamp
|
||||
let latestTime = -1;
|
||||
for (const msg of messages) {
|
||||
if (msg.timestamp > latestTime) {
|
||||
startNode = msg;
|
||||
latestTime = msg.timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse from leaf to root, collecting messages
|
||||
let currentNode: DatabaseMessage | undefined = startNode;
|
||||
while (currentNode) {
|
||||
// Include message if it's not root, or if we want to include root
|
||||
if (currentNode.type !== 'root' || includeRoot) {
|
||||
result.push(currentNode);
|
||||
}
|
||||
|
||||
// Stop traversal if parent is null (reached root)
|
||||
if (currentNode.parent === null) {
|
||||
break;
|
||||
}
|
||||
currentNode = nodeMap.get(currentNode.parent);
|
||||
}
|
||||
|
||||
// Sort: system messages first, then by timestamp
|
||||
result.sort((a, b) => {
|
||||
if (a.role === MessageRole.SYSTEM && b.role !== MessageRole.SYSTEM) return -1;
|
||||
if (a.role !== MessageRole.SYSTEM && b.role === MessageRole.SYSTEM) return 1;
|
||||
|
||||
return a.timestamp - b.timestamp;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the leaf node (message with no children) for a given message branch.
|
||||
* Traverses down the tree following the last child until reaching a leaf.
|
||||
*
|
||||
* @param messages - All messages in the conversation
|
||||
* @param messageId - Starting message ID to find leaf for
|
||||
* @returns The leaf node ID, or the original messageId if no children
|
||||
*/
|
||||
export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string {
|
||||
const nodeMap = new Map<string, DatabaseMessage>();
|
||||
|
||||
// Build node map for quick lookups
|
||||
for (const msg of messages) {
|
||||
nodeMap.set(msg.id, msg);
|
||||
}
|
||||
|
||||
let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
|
||||
while (currentNode && currentNode.children.length > 0) {
|
||||
// Follow the last child (most recent branch)
|
||||
const lastChildId = currentNode.children[currentNode.children.length - 1];
|
||||
currentNode = nodeMap.get(lastChildId);
|
||||
}
|
||||
|
||||
return currentNode?.id ?? messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all descendant messages (children, grandchildren, etc.) of a given message.
|
||||
* This is used for cascading deletion to remove all messages in a branch.
|
||||
*
|
||||
* @param messages - All messages in the conversation
|
||||
* @param messageId - The root message ID to find descendants for
|
||||
* @returns Array of all descendant message IDs
|
||||
*/
|
||||
export function findDescendantMessages(
|
||||
messages: readonly DatabaseMessage[],
|
||||
messageId: string
|
||||
): string[] {
|
||||
const nodeMap = new Map<string, DatabaseMessage>();
|
||||
|
||||
// Build node map for quick lookups
|
||||
for (const msg of messages) {
|
||||
nodeMap.set(msg.id, msg);
|
||||
}
|
||||
|
||||
const descendants: string[] = [];
|
||||
const queue: string[] = [messageId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!;
|
||||
const currentNode = nodeMap.get(currentId);
|
||||
|
||||
if (currentNode) {
|
||||
// Add all children to the queue and descendants list
|
||||
for (const childId of currentNode.children) {
|
||||
descendants.push(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return descendants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets sibling information for a message, including all sibling IDs and current position.
|
||||
* Siblings are messages that share the same parent.
|
||||
*
|
||||
* @param messages - All messages in the conversation
|
||||
* @param messageId - The message to get sibling info for
|
||||
* @returns Sibling information including leaf node IDs for navigation
|
||||
*/
|
||||
export function getMessageSiblings(
|
||||
messages: readonly DatabaseMessage[],
|
||||
messageId: string
|
||||
): ChatMessageSiblingInfo | null {
|
||||
const nodeMap = new Map<string, DatabaseMessage>();
|
||||
|
||||
// Build node map for quick lookups
|
||||
for (const msg of messages) {
|
||||
nodeMap.set(msg.id, msg);
|
||||
}
|
||||
|
||||
const message = nodeMap.get(messageId);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle null parent (root message) case
|
||||
if (message.parent === null) {
|
||||
// No parent means this is likely a root node with no siblings
|
||||
return {
|
||||
message,
|
||||
siblingIds: [messageId],
|
||||
currentIndex: 0,
|
||||
totalSiblings: 1
|
||||
};
|
||||
}
|
||||
|
||||
const parentNode = nodeMap.get(message.parent);
|
||||
if (!parentNode) {
|
||||
// Parent not found - treat as single message
|
||||
return {
|
||||
message,
|
||||
siblingIds: [messageId],
|
||||
currentIndex: 0,
|
||||
totalSiblings: 1
|
||||
};
|
||||
}
|
||||
|
||||
// Get all sibling IDs (including self)
|
||||
const siblingIds = parentNode.children;
|
||||
|
||||
// Convert sibling message IDs to their corresponding leaf node IDs
|
||||
// This allows navigation between different conversation branches
|
||||
const siblingLeafIds = siblingIds.map((siblingId: string) => findLeafNode(messages, siblingId));
|
||||
|
||||
// Find current message's position among siblings
|
||||
const currentIndex = siblingIds.indexOf(messageId);
|
||||
|
||||
return {
|
||||
message,
|
||||
siblingIds: siblingLeafIds,
|
||||
currentIndex,
|
||||
totalSiblings: siblingIds.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a display-ready list of messages with sibling information for UI rendering.
|
||||
* This is the main function used by chat components to render conversation branches.
|
||||
*
|
||||
* @param messages - All messages in the conversation
|
||||
* @param leafNodeId - Current leaf node being viewed
|
||||
* @returns Array of messages with sibling navigation info
|
||||
*/
|
||||
export function getMessageDisplayList(
|
||||
messages: readonly DatabaseMessage[],
|
||||
leafNodeId: string
|
||||
): ChatMessageSiblingInfo[] {
|
||||
// Get the current conversation path
|
||||
const currentPath = filterByLeafNodeId(messages, leafNodeId, true);
|
||||
const result: ChatMessageSiblingInfo[] = [];
|
||||
|
||||
// Add sibling info for each message in the current path
|
||||
for (const message of currentPath) {
|
||||
if (message.type === 'root') {
|
||||
continue; // Skip root messages in display
|
||||
}
|
||||
|
||||
const siblingInfo = getMessageSiblings(messages, message.id);
|
||||
if (siblingInfo) {
|
||||
result.push(siblingInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a message has multiple siblings (indicating branching at that point).
|
||||
*
|
||||
* @param messages - All messages in the conversation
|
||||
* @param messageId - The message to check
|
||||
* @returns True if the message has siblings
|
||||
*/
|
||||
export function hasMessageSiblings(
|
||||
messages: readonly DatabaseMessage[],
|
||||
messageId: string
|
||||
): boolean {
|
||||
const siblingInfo = getMessageSiblings(messages, messageId);
|
||||
return siblingInfo ? siblingInfo.totalSiblings > 1 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next sibling message ID for navigation.
|
||||
*
|
||||
* @param messages - All messages in the conversation
|
||||
* @param messageId - Current message ID
|
||||
* @returns Next sibling's leaf node ID, or null if at the end
|
||||
*/
|
||||
export function getNextSibling(
|
||||
messages: readonly DatabaseMessage[],
|
||||
messageId: string
|
||||
): string | null {
|
||||
const siblingInfo = getMessageSiblings(messages, messageId);
|
||||
if (!siblingInfo || siblingInfo.currentIndex >= siblingInfo.totalSiblings - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return siblingInfo.siblingIds[siblingInfo.currentIndex + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the previous sibling message ID for navigation.
|
||||
*
|
||||
* @param messages - All messages in the conversation
|
||||
* @param messageId - Current message ID
|
||||
* @returns Previous sibling's leaf node ID, or null if at the beginning
|
||||
*/
|
||||
export function getPreviousSibling(
|
||||
messages: readonly DatabaseMessage[],
|
||||
messageId: string
|
||||
): string | null {
|
||||
const siblingInfo = getMessageSiblings(messages, messageId);
|
||||
if (!siblingInfo || siblingInfo.currentIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return siblingInfo.siblingIds[siblingInfo.currentIndex - 1];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Browser-only utility exports
|
||||
*
|
||||
* These utilities require browser APIs (DOM, Canvas, MediaRecorder, etc.)
|
||||
* and cannot be imported during SSR. Import from '$lib/utils/browser-only'
|
||||
* only in client-side code or components that are not server-rendered.
|
||||
*/
|
||||
|
||||
// Audio utilities (MediaRecorder API)
|
||||
export {
|
||||
AudioRecorder,
|
||||
convertToWav,
|
||||
createAudioFile,
|
||||
isAudioRecordingSupported
|
||||
} from './audio-recording';
|
||||
|
||||
// PDF processing utilities (pdfjs-dist with DOMMatrix)
|
||||
export {
|
||||
convertPDFToText,
|
||||
convertPDFToImage,
|
||||
isPdfFile as isPdfFileFromFile,
|
||||
isApplicationMimeType
|
||||
} from './pdf-processing';
|
||||
|
||||
// File conversion utilities (depends on pdf-processing)
|
||||
export { parseFilesToMessageExtras } from './convert-files-to-extra';
|
||||
|
||||
// File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png)
|
||||
export { processFilesToChatUploaded } from './process-uploaded-files';
|
||||
|
||||
// SVG utilities (Canvas/Image API)
|
||||
export { svgBase64UrlToPngDataURL, isSvgFile, isSvgMimeType } from './svg-to-png';
|
||||
|
||||
// WebP utilities (Canvas/Image API)
|
||||
export { webpBase64UrlToPngDataURL, isWebpFile, isWebpMimeType } from './webp-to-png';
|
||||
@@ -0,0 +1,292 @@
|
||||
import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* TTL Cache - Time-To-Live cache implementation for memory optimization
|
||||
*
|
||||
* Provides automatic expiration of cached entries to prevent memory bloat
|
||||
* in long-running sessions.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const cache = new TTLCache<string, ApiData>({ ttlMs: 5 * 60 * 1000 }); // 5 minutes
|
||||
* cache.set('key', data);
|
||||
* const value = cache.get('key'); // null if expired
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface TTLCacheOptions {
|
||||
/** Time-to-live in milliseconds. Default: 5 minutes */
|
||||
ttlMs?: number;
|
||||
/** Maximum number of entries. Oldest entries are evicted when exceeded. Default: 100 */
|
||||
maxEntries?: number;
|
||||
/** Callback when an entry expires or is evicted */
|
||||
onEvict?: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
lastAccessed: number;
|
||||
}
|
||||
|
||||
export class TTLCache<K extends string, V> {
|
||||
private cache = new Map<K, CacheEntry<V>>();
|
||||
private readonly ttlMs: number;
|
||||
private readonly maxEntries: number;
|
||||
private readonly onEvict?: (key: string, value: unknown) => void;
|
||||
|
||||
constructor(options: TTLCacheOptions = {}) {
|
||||
this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||
this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
|
||||
this.onEvict = options.onEvict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from cache. Returns null if expired or not found.
|
||||
*/
|
||||
get(key: K): V | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last accessed time for LRU-like behavior
|
||||
entry.lastAccessed = Date.now();
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in cache with TTL.
|
||||
*/
|
||||
set(key: K, value: V, customTtlMs?: number): void {
|
||||
// Evict oldest entries if at capacity
|
||||
if (this.cache.size >= this.maxEntries && !this.cache.has(key)) {
|
||||
this.evictOldest();
|
||||
}
|
||||
|
||||
const ttl = customTtlMs ?? this.ttlMs;
|
||||
const now = Date.now();
|
||||
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
expiresAt: now + ttl,
|
||||
lastAccessed: now
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists and is not expired.
|
||||
*/
|
||||
has(key: K): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific key from cache.
|
||||
*/
|
||||
delete(key: K): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && this.onEvict) {
|
||||
this.onEvict(key, entry.value);
|
||||
}
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all entries from cache.
|
||||
*/
|
||||
clear(): void {
|
||||
if (this.onEvict) {
|
||||
for (const [key, entry] of this.cache) {
|
||||
this.onEvict(key, entry.value);
|
||||
}
|
||||
}
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of entries (including potentially expired ones).
|
||||
*/
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all expired entries from cache.
|
||||
* Call periodically for proactive cleanup.
|
||||
*/
|
||||
prune(): number {
|
||||
const now = Date.now();
|
||||
let pruned = 0;
|
||||
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.delete(key);
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
|
||||
return pruned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid (non-expired) keys.
|
||||
*/
|
||||
keys(): K[] {
|
||||
const now = Date.now();
|
||||
const validKeys: K[] = [];
|
||||
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (now <= entry.expiresAt) {
|
||||
validKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return validKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict the oldest (least recently accessed) entry.
|
||||
*/
|
||||
private evictOldest(): void {
|
||||
let oldestKey: K | null = null;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (entry.lastAccessed < oldestTime) {
|
||||
oldestTime = entry.lastAccessed;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey !== null) {
|
||||
this.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh TTL for an existing entry without changing the value.
|
||||
*/
|
||||
touch(key: K): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
|
||||
const now = Date.now();
|
||||
if (now > entry.expiresAt) {
|
||||
this.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.expiresAt = now + this.ttlMs;
|
||||
entry.lastAccessed = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive TTL Map for Svelte stores
|
||||
* Wraps SvelteMap with TTL functionality
|
||||
*/
|
||||
export class ReactiveTTLMap<K extends string, V> {
|
||||
private entries = $state<Map<K, CacheEntry<V>>>(new Map());
|
||||
private readonly ttlMs: number;
|
||||
private readonly maxEntries: number;
|
||||
|
||||
constructor(options: TTLCacheOptions = {}) {
|
||||
this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||
this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
|
||||
}
|
||||
|
||||
get(key: K): V | null {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.entries.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
entry.lastAccessed = Date.now();
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: K, value: V, customTtlMs?: number): void {
|
||||
if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
|
||||
this.evictOldest();
|
||||
}
|
||||
|
||||
const ttl = customTtlMs ?? this.ttlMs;
|
||||
const now = Date.now();
|
||||
|
||||
this.entries.set(key, {
|
||||
value,
|
||||
expiresAt: now + ttl,
|
||||
lastAccessed: now
|
||||
});
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return false;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.entries.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
delete(key: K): boolean {
|
||||
return this.entries.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
prune(): number {
|
||||
const now = Date.now();
|
||||
let pruned = 0;
|
||||
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.entries.delete(key);
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
|
||||
return pruned;
|
||||
}
|
||||
|
||||
private evictOldest(): void {
|
||||
let oldestKey: K | null = null;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.lastAccessed < oldestTime) {
|
||||
oldestTime = entry.lastAccessed;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey !== null) {
|
||||
this.entries.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type {
|
||||
DatabaseMessageExtra,
|
||||
DatabaseMessageExtraTextFile,
|
||||
DatabaseMessageExtraLegacyContext,
|
||||
DatabaseMessageExtraMcpPrompt,
|
||||
DatabaseMessageExtraMcpResource,
|
||||
ClipboardTextAttachment,
|
||||
ClipboardMcpPromptAttachment,
|
||||
ClipboardAttachment,
|
||||
ParsedClipboardContent
|
||||
} from '$lib/types';
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with toast notification
|
||||
* Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
|
||||
* @param text - Text to copy to clipboard
|
||||
* @param successMessage - Custom success message (optional)
|
||||
* @param errorMessage - Custom error message (optional)
|
||||
* @returns Promise<boolean> - True if successful, false otherwise
|
||||
*/
|
||||
export async function copyToClipboard(
|
||||
text: string,
|
||||
successMessage = 'Copied to clipboard',
|
||||
errorMessage = 'Failed to copy to clipboard'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Try modern clipboard API first (secure contexts only)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(successMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback for non-secure contexts
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
toast.success(successMessage);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error('execCommand failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy code with HTML entity decoding and toast notification
|
||||
* @param rawCode - Raw code string that may contain HTML entities
|
||||
* @param successMessage - Custom success message (optional)
|
||||
* @param errorMessage - Custom error message (optional)
|
||||
* @returns Promise<boolean> - True if successful, false otherwise
|
||||
*/
|
||||
export async function copyCodeToClipboard(
|
||||
rawCode: string,
|
||||
successMessage = 'Code copied to clipboard',
|
||||
errorMessage = 'Failed to copy code'
|
||||
): Promise<boolean> {
|
||||
return copyToClipboard(rawCode, successMessage, errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a message with text attachments for clipboard copying.
|
||||
*
|
||||
* Default format (asPlainText = false):
|
||||
* ```
|
||||
* "Text message content"
|
||||
* [
|
||||
* {"type":"TEXT","name":"filename.txt","content":"..."},
|
||||
* {"type":"TEXT","name":"another.txt","content":"..."}
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* Plain text format (asPlainText = true):
|
||||
* ```
|
||||
* Text message content
|
||||
*
|
||||
* file content here
|
||||
*
|
||||
* another file content
|
||||
* ```
|
||||
*
|
||||
* @param content - The message text content
|
||||
* @param extras - Optional array of message attachments
|
||||
* @param asPlainText - If true, format as plain text without JSON structure
|
||||
* @returns Formatted string for clipboard
|
||||
*/
|
||||
export function formatMessageForClipboard(
|
||||
content: string,
|
||||
extras?: DatabaseMessageExtra[],
|
||||
asPlainText: boolean = false
|
||||
): string {
|
||||
// Filter text-like attachments (TEXT, LEGACY_CONTEXT, MCP_PROMPT, and MCP_RESOURCE types)
|
||||
const textAttachments =
|
||||
extras?.filter(
|
||||
(
|
||||
extra
|
||||
): extra is
|
||||
| DatabaseMessageExtraTextFile
|
||||
| DatabaseMessageExtraLegacyContext
|
||||
| DatabaseMessageExtraMcpPrompt
|
||||
| DatabaseMessageExtraMcpResource =>
|
||||
extra.type === AttachmentType.TEXT ||
|
||||
extra.type === AttachmentType.LEGACY_CONTEXT ||
|
||||
extra.type === AttachmentType.MCP_PROMPT ||
|
||||
extra.type === AttachmentType.MCP_RESOURCE
|
||||
) ?? [];
|
||||
|
||||
if (textAttachments.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (asPlainText) {
|
||||
const parts = [content];
|
||||
for (const att of textAttachments) {
|
||||
parts.push(att.content);
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
const clipboardAttachments: ClipboardAttachment[] = textAttachments.map((att) => {
|
||||
if (att.type === AttachmentType.MCP_PROMPT) {
|
||||
const mcpAtt = att as DatabaseMessageExtraMcpPrompt;
|
||||
return {
|
||||
type: AttachmentType.MCP_PROMPT,
|
||||
name: mcpAtt.name,
|
||||
serverName: mcpAtt.serverName,
|
||||
promptName: mcpAtt.promptName,
|
||||
content: mcpAtt.content,
|
||||
arguments: mcpAtt.arguments
|
||||
} as ClipboardMcpPromptAttachment;
|
||||
}
|
||||
return {
|
||||
type: AttachmentType.TEXT,
|
||||
name: att.name,
|
||||
content: att.content
|
||||
} as ClipboardTextAttachment;
|
||||
});
|
||||
|
||||
return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses clipboard content to extract message and text attachments.
|
||||
* Supports both plain text and the special format with attachments.
|
||||
*
|
||||
* @param clipboardText - Raw text from clipboard
|
||||
* @returns Parsed content with message and attachments
|
||||
*/
|
||||
export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
|
||||
const defaultResult: ParsedClipboardContent = {
|
||||
message: clipboardText,
|
||||
textAttachments: [],
|
||||
mcpPromptAttachments: []
|
||||
};
|
||||
|
||||
if (!clipboardText.startsWith('"')) {
|
||||
return defaultResult;
|
||||
}
|
||||
|
||||
try {
|
||||
let stringEndIndex = -1;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 1; i < clipboardText.length; i++) {
|
||||
const char = clipboardText[i];
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
stringEndIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stringEndIndex === -1) {
|
||||
return defaultResult;
|
||||
}
|
||||
|
||||
const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1);
|
||||
const remainingPart = clipboardText.substring(stringEndIndex + 1).trim();
|
||||
|
||||
const message = JSON.parse(jsonStringPart) as string;
|
||||
|
||||
if (!remainingPart || !remainingPart.startsWith('[')) {
|
||||
return {
|
||||
message,
|
||||
textAttachments: [],
|
||||
mcpPromptAttachments: []
|
||||
};
|
||||
}
|
||||
|
||||
const attachments = JSON.parse(remainingPart) as unknown[];
|
||||
|
||||
const validTextAttachments: ClipboardTextAttachment[] = [];
|
||||
const validMcpPromptAttachments: ClipboardMcpPromptAttachment[] = [];
|
||||
|
||||
for (const att of attachments) {
|
||||
if (isValidMcpPromptAttachment(att)) {
|
||||
validMcpPromptAttachments.push({
|
||||
type: AttachmentType.MCP_PROMPT,
|
||||
name: att.name,
|
||||
serverName: att.serverName,
|
||||
promptName: att.promptName,
|
||||
content: att.content,
|
||||
arguments: att.arguments
|
||||
});
|
||||
} else if (isValidTextAttachment(att)) {
|
||||
validTextAttachments.push({
|
||||
type: AttachmentType.TEXT,
|
||||
name: att.name,
|
||||
content: att.content
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message,
|
||||
textAttachments: validTextAttachments,
|
||||
mcpPromptAttachments: validMcpPromptAttachments
|
||||
};
|
||||
} catch {
|
||||
return defaultResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate an MCP prompt attachment object
|
||||
* @param obj The object to validate
|
||||
* @returns true if the object is a valid MCP prompt attachment
|
||||
*/
|
||||
function isValidMcpPromptAttachment(obj: unknown): obj is {
|
||||
type: string;
|
||||
name: string;
|
||||
serverName: string;
|
||||
promptName: string;
|
||||
content: string;
|
||||
arguments?: Record<string, string>;
|
||||
} {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
(record.type === AttachmentType.MCP_PROMPT || record.type === 'MCP_PROMPT') &&
|
||||
typeof record.name === 'string' &&
|
||||
typeof record.serverName === 'string' &&
|
||||
typeof record.promptName === 'string' &&
|
||||
typeof record.content === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate a text attachment object
|
||||
* @param obj The object to validate
|
||||
* @returns true if the object is a valid text attachment
|
||||
*/
|
||||
function isValidTextAttachment(
|
||||
obj: unknown
|
||||
): obj is { type: string; name: string; content: string } {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
(record.type === AttachmentType.TEXT || record.type === 'TEXT') &&
|
||||
typeof record.name === 'string' &&
|
||||
typeof record.content === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if clipboard content contains our special format with attachments
|
||||
* @param clipboardText - Raw text from clipboard
|
||||
* @returns true if the clipboard content contains our special format with attachments
|
||||
*/
|
||||
export function hasClipboardAttachments(clipboardText: string): boolean {
|
||||
if (!clipboardText.startsWith('"')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseClipboardContent(clipboardText);
|
||||
return parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import hljs from 'highlight.js';
|
||||
import {
|
||||
NEWLINE,
|
||||
DEFAULT_LANGUAGE,
|
||||
LANG_PATTERN,
|
||||
AMPERSAND_REGEX,
|
||||
LT_REGEX,
|
||||
GT_REGEX,
|
||||
FENCE_PATTERN
|
||||
} from '$lib/constants';
|
||||
|
||||
export interface IncompleteCodeBlock {
|
||||
language: string;
|
||||
code: string;
|
||||
openingIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights code using highlight.js
|
||||
* @param code - The code to highlight
|
||||
* @param language - The programming language
|
||||
* @returns HTML string with syntax highlighting
|
||||
*/
|
||||
export function highlightCode(code: string, language: string): string {
|
||||
if (!code) return '';
|
||||
|
||||
try {
|
||||
const lang = language.toLowerCase();
|
||||
const isSupported = hljs.getLanguage(lang);
|
||||
|
||||
if (isSupported) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
} else {
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to escaped plain text
|
||||
return code
|
||||
.replace(AMPERSAND_REGEX, '&')
|
||||
.replace(LT_REGEX, '<')
|
||||
.replace(GT_REGEX, '>');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if markdown ends with an incomplete code block (opened but not closed).
|
||||
* Returns the code block info if found, null otherwise.
|
||||
* @param markdown - The raw markdown string to check
|
||||
* @returns IncompleteCodeBlock info or null
|
||||
*/
|
||||
export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock | null {
|
||||
// Count all code fences in the markdown
|
||||
// A code block is incomplete if there's an odd number of ``` fences
|
||||
const fencePattern = new RegExp(FENCE_PATTERN.source, FENCE_PATTERN.flags);
|
||||
const fences: number[] = [];
|
||||
let fenceMatch;
|
||||
|
||||
while ((fenceMatch = fencePattern.exec(markdown)) !== null) {
|
||||
// Store the position after the ```
|
||||
const pos = fenceMatch[0].startsWith(NEWLINE) ? fenceMatch.index + 1 : fenceMatch.index;
|
||||
fences.push(pos);
|
||||
}
|
||||
|
||||
// If even number of fences (including 0), all code blocks are closed
|
||||
if (fences.length % 2 === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Odd number means last code block is incomplete
|
||||
// The last fence is the opening of the incomplete block
|
||||
const openingIndex = fences[fences.length - 1];
|
||||
const afterOpening = markdown.slice(openingIndex + 3);
|
||||
|
||||
// Extract language and code content
|
||||
const langMatch = afterOpening.match(LANG_PATTERN);
|
||||
const language = langMatch?.[1] || DEFAULT_LANGUAGE;
|
||||
const codeStartIndex = openingIndex + 3 + (langMatch?.[0]?.length ?? 0);
|
||||
const code = markdown.slice(codeStartIndex);
|
||||
|
||||
return {
|
||||
language,
|
||||
code,
|
||||
openingIndex
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Type-safe configuration helpers
|
||||
*
|
||||
* Provides utilities for safely accessing and modifying configuration objects
|
||||
* with dynamic keys while maintaining TypeScript type safety.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type-safe helper to access config properties dynamically
|
||||
* Provides better type safety than direct casting to Record
|
||||
*/
|
||||
export function setConfigValue<T extends SettingsConfigType>(
|
||||
config: T,
|
||||
key: string,
|
||||
value: unknown
|
||||
): void {
|
||||
if (key in config) {
|
||||
(config as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe helper to get config values dynamically
|
||||
*/
|
||||
export function getConfigValue<T extends SettingsConfigType>(
|
||||
config: T,
|
||||
key: string
|
||||
): string | number | boolean | undefined {
|
||||
const value = (config as Record<string, unknown>)[key];
|
||||
return value as string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a SettingsConfigType to a ParameterRecord for specific keys
|
||||
* Useful for parameter synchronization operations
|
||||
*/
|
||||
export function configToParameterRecord<T extends SettingsConfigType>(
|
||||
config: T,
|
||||
keys: string[]
|
||||
): Record<string, string | number | boolean> {
|
||||
const record: Record<string, string | number | boolean> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
const value = getConfigValue(config, key);
|
||||
if (value !== undefined) {
|
||||
record[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Utility functions for conversation data manipulation
|
||||
*/
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Creates a map of conversation IDs to their message counts from exported conversation data
|
||||
* @param exportedData - Array of exported conversations with their messages
|
||||
* @returns Map of conversation ID to message count
|
||||
*/
|
||||
export function createMessageCountMap(
|
||||
exportedData: Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>
|
||||
): Map<string, number> {
|
||||
const countMap = new Map<string, number>();
|
||||
|
||||
for (const item of exportedData) {
|
||||
countMap.set(item.conv.id, item.messages.length);
|
||||
}
|
||||
|
||||
return countMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message count for a specific conversation from the count map
|
||||
* @param conversationId - The ID of the conversation
|
||||
* @param countMap - Map of conversation IDs to message counts
|
||||
* @returns The message count, or 0 if not found
|
||||
*/
|
||||
export function getMessageCount(conversationId: string, countMap: Map<string, number>): number {
|
||||
return countMap.get(conversationId) ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { convertPDFToImage, convertPDFToText } from './pdf-processing';
|
||||
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
|
||||
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
|
||||
import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
import { readFileAsText, isLikelyTextFile } from './text-files';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { FileProcessingResult, ChatUploadedFile, DatabaseMessageExtra } from '$lib/types';
|
||||
|
||||
function readFileAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
// Extract base64 data without the data URL prefix
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error);
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseFilesToMessageExtras(
|
||||
files: ChatUploadedFile[],
|
||||
activeModelId?: string
|
||||
): Promise<FileProcessingResult> {
|
||||
const extras: DatabaseMessageExtra[] = [];
|
||||
const emptyFiles: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.type === SpecialFileType.MCP_PROMPT && file.mcpPrompt) {
|
||||
extras.push({
|
||||
type: AttachmentType.MCP_PROMPT,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
serverName: file.mcpPrompt.serverName,
|
||||
promptName: file.mcpPrompt.promptName,
|
||||
content: file.textContent ?? '',
|
||||
arguments: file.mcpPrompt.arguments
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
|
||||
if (file.preview) {
|
||||
let base64Url = file.preview;
|
||||
|
||||
if (isSvgMimeType(file.type)) {
|
||||
try {
|
||||
base64Url = await svgBase64UrlToPngDataURL(base64Url);
|
||||
} catch (error) {
|
||||
console.error('Failed to convert SVG to PNG for database storage:', error);
|
||||
}
|
||||
} else if (isWebpMimeType(file.type)) {
|
||||
try {
|
||||
base64Url = await webpBase64UrlToPngDataURL(base64Url);
|
||||
} catch (error) {
|
||||
console.error('Failed to convert WebP to PNG for database storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
extras.push({
|
||||
type: AttachmentType.IMAGE,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
base64Url
|
||||
});
|
||||
}
|
||||
} else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
|
||||
// Process audio files (MP3 and WAV)
|
||||
try {
|
||||
const base64Data = await readFileAsBase64(file.file);
|
||||
|
||||
extras.push({
|
||||
type: AttachmentType.AUDIO,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
base64Data: base64Data,
|
||||
mimeType: file.type
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to process audio file ${file.name}:`, error);
|
||||
}
|
||||
} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
|
||||
try {
|
||||
// Always get base64 data for preview functionality
|
||||
const base64Data = await readFileAsBase64(file.file);
|
||||
const currentConfig = config();
|
||||
// Use per-model vision check for router mode
|
||||
const hasVisionSupport = activeModelId
|
||||
? modelsStore.modelSupportsVision(activeModelId)
|
||||
: false;
|
||||
|
||||
// Force PDF-to-text for non-vision models
|
||||
let shouldProcessAsImages = Boolean(currentConfig.pdfAsImage) && hasVisionSupport;
|
||||
|
||||
// If user had pdfAsImage enabled but model doesn't support vision, update setting and notify
|
||||
if (currentConfig.pdfAsImage && !hasVisionSupport) {
|
||||
console.log('Non-vision model detected: forcing PDF-to-text mode and updating settings');
|
||||
|
||||
// Update the setting in localStorage
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, false);
|
||||
|
||||
// Show toast notification to user
|
||||
toast.warning(
|
||||
'PDF setting changed: Non-vision model detected, PDFs will be processed as text instead of images.',
|
||||
{
|
||||
duration: 5000
|
||||
}
|
||||
);
|
||||
|
||||
shouldProcessAsImages = false;
|
||||
}
|
||||
|
||||
if (shouldProcessAsImages) {
|
||||
// Process PDF as images (only for vision models)
|
||||
try {
|
||||
const images = await convertPDFToImage(file.file);
|
||||
|
||||
// Show success toast for PDF image processing
|
||||
toast.success(
|
||||
`PDF "${file.name}" processed as ${images.length} images for vision model.`,
|
||||
{
|
||||
duration: 3000
|
||||
}
|
||||
);
|
||||
|
||||
extras.push({
|
||||
type: AttachmentType.PDF,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
content: `PDF file with ${images.length} pages`,
|
||||
images: images,
|
||||
processedAsImages: true,
|
||||
base64Data: base64Data
|
||||
});
|
||||
} catch (imageError) {
|
||||
console.warn(
|
||||
`Failed to process PDF ${file.name} as images, falling back to text:`,
|
||||
imageError
|
||||
);
|
||||
|
||||
// Fallback to text processing
|
||||
const content = await convertPDFToText(file.file);
|
||||
|
||||
extras.push({
|
||||
type: AttachmentType.PDF,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
content: content,
|
||||
processedAsImages: false,
|
||||
base64Data: base64Data
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Process PDF as text (default or forced for non-vision models)
|
||||
const content = await convertPDFToText(file.file);
|
||||
|
||||
// Show success toast for PDF text processing
|
||||
toast.success(`PDF "${file.name}" processed as text content.`, {
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
extras.push({
|
||||
type: AttachmentType.PDF,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
content: content,
|
||||
processedAsImages: false,
|
||||
base64Data: base64Data
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to process PDF file ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const content = await readFileAsText(file.file);
|
||||
|
||||
// Check if file is empty
|
||||
if (content.trim() === '') {
|
||||
console.warn(`File ${file.name} is empty and will be skipped`);
|
||||
emptyFiles.push(file.name);
|
||||
} else if (isLikelyTextFile(content)) {
|
||||
extras.push({
|
||||
type: AttachmentType.TEXT,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
content: content
|
||||
});
|
||||
} else {
|
||||
console.warn(`File ${file.name} appears to be binary and will be skipped`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to read file ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { extras, emptyFiles };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* CORS Proxy utility for routing requests through llama-server's CORS proxy.
|
||||
*/
|
||||
|
||||
import { base } from '$app/paths';
|
||||
import { CORS_PROXY_ENDPOINT, CORS_PROXY_URL_PARAM } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Build a proxied URL that routes through llama-server's CORS proxy.
|
||||
* @param targetUrl - The original URL to proxy
|
||||
* @returns URL pointing to the CORS proxy with target encoded
|
||||
*/
|
||||
export function buildProxiedUrl(targetUrl: string): URL {
|
||||
const proxyPath = `${base}${CORS_PROXY_ENDPOINT}`;
|
||||
const proxyUrl = new URL(proxyPath, window.location.origin);
|
||||
|
||||
proxyUrl.searchParams.set(CORS_PROXY_URL_PARAM, targetUrl);
|
||||
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap original headers for proxying through the CORS proxy. This avoids issues with duplicated llama.cpp-specific and target headers when using the CORS proxy.
|
||||
* @param headers - The original headers to be proxied to target
|
||||
* @returns List of "wrapped" headers to be sent to the CORS proxy
|
||||
*/
|
||||
export function buildProxiedHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const proxiedHeaders: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
proxiedHeaders[`x-proxy-header-${key}`] = value;
|
||||
}
|
||||
|
||||
return proxiedHeaders;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Converts a rem CSS value to pixels based on the document root font size.
|
||||
*/
|
||||
export function remToPx(rem: string): number {
|
||||
const val = parseFloat(rem);
|
||||
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
|
||||
return val * fontSize;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Creates a base64 data URL from MIME type and base64-encoded data.
|
||||
*
|
||||
* @param mimeType - The MIME type (e.g., 'image/png', 'audio/mp3')
|
||||
* @param base64Data - The base64-encoded data
|
||||
* @returns A data URL string in format 'data:{mimeType};base64,{data}'
|
||||
*/
|
||||
export function createBase64DataUrl(mimeType: string, base64Data: string): string {
|
||||
return `data:${mimeType};base64,${base64Data}`;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @param fn - The function to debounce
|
||||
* @param delay - The delay in milliseconds
|
||||
* @returns A debounced version of the function
|
||||
*/
|
||||
export function debounce<T extends (...args: Parameters<T>) => void>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
fn(...args);
|
||||
timeoutId = null;
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Gets a display label for a file type from various input formats
|
||||
*
|
||||
* Handles:
|
||||
* - MIME types: 'application/pdf' → 'PDF'
|
||||
* - AttachmentType values: 'PDF', 'AUDIO' → 'PDF', 'AUDIO'
|
||||
* - File names: 'document.pdf' → 'PDF'
|
||||
* - Unknown: returns 'FILE'
|
||||
*
|
||||
* @param input - MIME type, AttachmentType value, or file name
|
||||
* @returns Formatted file type label (uppercase)
|
||||
*/
|
||||
export function getFileTypeLabel(input: string | undefined): string {
|
||||
if (!input) return 'FILE';
|
||||
|
||||
// Handle MIME types (contains '/')
|
||||
if (input.includes('/')) {
|
||||
const subtype = input.split('/').pop();
|
||||
if (subtype) {
|
||||
// Handle special cases like 'vnd.ms-excel' → 'EXCEL'
|
||||
if (subtype.includes('.')) {
|
||||
return subtype.split('.').pop()?.toUpperCase() || 'FILE';
|
||||
}
|
||||
return subtype.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file names (contains '.')
|
||||
if (input.includes('.')) {
|
||||
const ext = input.split('.').pop();
|
||||
if (ext) return ext.toUpperCase();
|
||||
}
|
||||
|
||||
// Handle AttachmentType or other plain strings
|
||||
return input.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
AUDIO_FILE_TYPES,
|
||||
IMAGE_FILE_TYPES,
|
||||
PDF_FILE_TYPES,
|
||||
TEXT_FILE_TYPES
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
FileExtensionAudio,
|
||||
FileExtensionImage,
|
||||
FileExtensionPdf,
|
||||
FileExtensionText,
|
||||
FileTypeCategory,
|
||||
MimeTypeApplication,
|
||||
MimeTypeAudio,
|
||||
MimeTypeImage,
|
||||
MimeTypeText
|
||||
} from '$lib/enums';
|
||||
|
||||
export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
|
||||
switch (mimeType) {
|
||||
// Images
|
||||
case MimeTypeImage.JPEG:
|
||||
case MimeTypeImage.PNG:
|
||||
case MimeTypeImage.GIF:
|
||||
case MimeTypeImage.WEBP:
|
||||
case MimeTypeImage.SVG:
|
||||
return FileTypeCategory.IMAGE;
|
||||
|
||||
// Audio
|
||||
case MimeTypeAudio.MP3_MPEG:
|
||||
case MimeTypeAudio.MP3:
|
||||
case MimeTypeAudio.MP4:
|
||||
case MimeTypeAudio.WAV:
|
||||
case MimeTypeAudio.WEBM:
|
||||
case MimeTypeAudio.WEBM_OPUS:
|
||||
return FileTypeCategory.AUDIO;
|
||||
|
||||
// PDF
|
||||
case MimeTypeApplication.PDF:
|
||||
return FileTypeCategory.PDF;
|
||||
|
||||
// Text
|
||||
case MimeTypeText.PLAIN:
|
||||
case MimeTypeText.MARKDOWN:
|
||||
case MimeTypeText.ASCIIDOC:
|
||||
case MimeTypeText.JAVASCRIPT:
|
||||
case MimeTypeText.JAVASCRIPT_APP:
|
||||
case MimeTypeText.TYPESCRIPT:
|
||||
case MimeTypeText.JSX:
|
||||
case MimeTypeText.TSX:
|
||||
case MimeTypeText.CSS:
|
||||
case MimeTypeText.HTML:
|
||||
case MimeTypeText.JSON:
|
||||
case MimeTypeText.XML_TEXT:
|
||||
case MimeTypeText.XML_APP:
|
||||
case MimeTypeText.YAML_TEXT:
|
||||
case MimeTypeText.YAML_APP:
|
||||
case MimeTypeText.CSV:
|
||||
case MimeTypeText.PYTHON:
|
||||
case MimeTypeText.JAVA:
|
||||
case MimeTypeText.CPP_SRC:
|
||||
case MimeTypeText.C_SRC:
|
||||
case MimeTypeText.C_HDR:
|
||||
case MimeTypeText.PHP:
|
||||
case MimeTypeText.RUBY:
|
||||
case MimeTypeText.GO:
|
||||
case MimeTypeText.RUST:
|
||||
case MimeTypeText.SHELL:
|
||||
case MimeTypeText.BAT:
|
||||
case MimeTypeText.SQL:
|
||||
case MimeTypeText.R:
|
||||
case MimeTypeText.SCALA:
|
||||
case MimeTypeText.KOTLIN:
|
||||
case MimeTypeText.SWIFT:
|
||||
case MimeTypeText.DART:
|
||||
case MimeTypeText.VUE:
|
||||
case MimeTypeText.SVELTE:
|
||||
case MimeTypeText.LATEX:
|
||||
case MimeTypeText.BIBTEX:
|
||||
case MimeTypeText.CUDA:
|
||||
case MimeTypeText.CPP_HDR:
|
||||
case MimeTypeText.CSHARP:
|
||||
case MimeTypeText.HASKELL:
|
||||
case MimeTypeText.PROPERTIES:
|
||||
case MimeTypeText.TEX:
|
||||
case MimeTypeText.TEX_APP:
|
||||
return FileTypeCategory.TEXT;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileTypeCategoryByExtension(filename: string): FileTypeCategory | null {
|
||||
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
|
||||
switch (extension) {
|
||||
// Images
|
||||
case FileExtensionImage.JPG:
|
||||
case FileExtensionImage.JPEG:
|
||||
case FileExtensionImage.PNG:
|
||||
case FileExtensionImage.GIF:
|
||||
case FileExtensionImage.WEBP:
|
||||
case FileExtensionImage.SVG:
|
||||
return FileTypeCategory.IMAGE;
|
||||
|
||||
// Audio
|
||||
case FileExtensionAudio.MP3:
|
||||
case FileExtensionAudio.WAV:
|
||||
return FileTypeCategory.AUDIO;
|
||||
|
||||
// PDF
|
||||
case FileExtensionPdf.PDF:
|
||||
return FileTypeCategory.PDF;
|
||||
|
||||
// Text
|
||||
case FileExtensionText.TXT:
|
||||
case FileExtensionText.MD:
|
||||
case FileExtensionText.ADOC:
|
||||
case FileExtensionText.JS:
|
||||
case FileExtensionText.TS:
|
||||
case FileExtensionText.JSX:
|
||||
case FileExtensionText.TSX:
|
||||
case FileExtensionText.CSS:
|
||||
case FileExtensionText.HTML:
|
||||
case FileExtensionText.HTM:
|
||||
case FileExtensionText.JSON:
|
||||
case FileExtensionText.XML:
|
||||
case FileExtensionText.YAML:
|
||||
case FileExtensionText.YML:
|
||||
case FileExtensionText.CSV:
|
||||
case FileExtensionText.LOG:
|
||||
case FileExtensionText.PY:
|
||||
case FileExtensionText.JAVA:
|
||||
case FileExtensionText.CPP:
|
||||
case FileExtensionText.C:
|
||||
case FileExtensionText.H:
|
||||
case FileExtensionText.PHP:
|
||||
case FileExtensionText.RB:
|
||||
case FileExtensionText.GO:
|
||||
case FileExtensionText.RS:
|
||||
case FileExtensionText.SH:
|
||||
case FileExtensionText.BAT:
|
||||
case FileExtensionText.SQL:
|
||||
case FileExtensionText.R:
|
||||
case FileExtensionText.SCALA:
|
||||
case FileExtensionText.KT:
|
||||
case FileExtensionText.SWIFT:
|
||||
case FileExtensionText.DART:
|
||||
case FileExtensionText.VUE:
|
||||
case FileExtensionText.SVELTE:
|
||||
case FileExtensionText.TEX:
|
||||
case FileExtensionText.BIB:
|
||||
case FileExtensionText.COMP:
|
||||
case FileExtensionText.CU:
|
||||
case FileExtensionText.CUH:
|
||||
case FileExtensionText.HPP:
|
||||
case FileExtensionText.HS:
|
||||
case FileExtensionText.PROPERTIES:
|
||||
return FileTypeCategory.TEXT;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileTypeByExtension(filename: string): string | null {
|
||||
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
|
||||
for (const [key, type] of Object.entries(IMAGE_FILE_TYPES)) {
|
||||
if ((type.extensions as readonly string[]).includes(extension)) {
|
||||
return `${FileTypeCategory.IMAGE}:${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, type] of Object.entries(AUDIO_FILE_TYPES)) {
|
||||
if ((type.extensions as readonly string[]).includes(extension)) {
|
||||
return `${FileTypeCategory.AUDIO}:${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, type] of Object.entries(PDF_FILE_TYPES)) {
|
||||
if ((type.extensions as readonly string[]).includes(extension)) {
|
||||
return `${FileTypeCategory.PDF}:${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, type] of Object.entries(TEXT_FILE_TYPES)) {
|
||||
if ((type.extensions as readonly string[]).includes(extension)) {
|
||||
return `${FileTypeCategory.TEXT}:${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isFileTypeSupported(filename: string, mimeType?: string): boolean {
|
||||
// Images are detected and handled separately for vision models
|
||||
if (mimeType) {
|
||||
const category = getFileTypeCategory(mimeType);
|
||||
if (
|
||||
category === FileTypeCategory.IMAGE ||
|
||||
category === FileTypeCategory.AUDIO ||
|
||||
category === FileTypeCategory.PDF
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check extension for known types (especially images without MIME)
|
||||
const extCategory = getFileTypeCategoryByExtension(filename);
|
||||
if (
|
||||
extCategory === FileTypeCategory.IMAGE ||
|
||||
extCategory === FileTypeCategory.AUDIO ||
|
||||
extCategory === FileTypeCategory.PDF
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: treat everything else as text (inclusive by default)
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
MS_PER_SECOND,
|
||||
SECONDS_PER_MINUTE,
|
||||
SECONDS_PER_HOUR,
|
||||
SHORT_DURATION_THRESHOLD,
|
||||
MEDIUM_DURATION_THRESHOLD
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Formats file size in bytes to human readable format
|
||||
* Supports Bytes, KB, MB, and GB
|
||||
*
|
||||
* @param bytes - File size in bytes (or unknown for safety)
|
||||
* @returns Formatted file size string
|
||||
*/
|
||||
export function formatFileSize(bytes: number | unknown): string {
|
||||
if (typeof bytes !== 'number') return 'Unknown';
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parameter count to human-readable format (B, M, K)
|
||||
*
|
||||
* @param params - Parameter count
|
||||
* @returns Human-readable parameter count
|
||||
*/
|
||||
export function formatParameters(params: number | unknown): string {
|
||||
if (typeof params !== 'number') return 'Unknown';
|
||||
|
||||
if (params >= 1e9) {
|
||||
return `${(params / 1e9).toFixed(2)}B`;
|
||||
}
|
||||
|
||||
if (params >= 1e6) {
|
||||
return `${(params / 1e6).toFixed(2)}M`;
|
||||
}
|
||||
|
||||
if (params >= 1e3) {
|
||||
return `${(params / 1e3).toFixed(2)}K`;
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with locale-specific thousands separators
|
||||
*
|
||||
* @param num - Number to format
|
||||
* @returns Human-readable number
|
||||
*/
|
||||
export function formatNumber(num: number | unknown): string {
|
||||
if (typeof num !== 'number') return 'Unknown';
|
||||
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format JSON string with pretty printing (2-space indentation)
|
||||
* Returns original string if parsing fails
|
||||
*
|
||||
* @param jsonString - JSON string to format
|
||||
* @returns Pretty-printed JSON string or original if invalid
|
||||
*/
|
||||
export function formatJsonPretty(jsonString: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return jsonString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time as HH:MM:SS in 24-hour format
|
||||
*
|
||||
* @param date - Date object to format
|
||||
* @returns Formatted time string (HH:MM:SS)
|
||||
*/
|
||||
export function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats milliseconds to a human-readable time string for performance metrics.
|
||||
* Examples: "4h 12min 54s", "12min 34s", "45s", "0.5s"
|
||||
*
|
||||
* @param ms - Time in milliseconds
|
||||
* @returns Formatted time string
|
||||
*/
|
||||
export function formatPerformanceTime(ms: number): string {
|
||||
if (ms < 0) return '0s';
|
||||
|
||||
const totalSeconds = ms / MS_PER_SECOND;
|
||||
|
||||
if (totalSeconds < SHORT_DURATION_THRESHOLD) {
|
||||
return `${totalSeconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
if (totalSeconds < MEDIUM_DURATION_THRESHOLD) {
|
||||
return `${totalSeconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
|
||||
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
|
||||
const seconds = Math.floor(totalSeconds % SECONDS_PER_MINUTE);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`);
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}min`);
|
||||
}
|
||||
|
||||
if (seconds > 0 || parts.length === 0) {
|
||||
parts.push(`${seconds}s`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats attachment content for API requests with consistent header style.
|
||||
* Used when converting message attachments to text content parts.
|
||||
*
|
||||
* @param label - Type label (e.g., 'File', 'PDF File', 'MCP Prompt')
|
||||
* @param name - File or attachment name
|
||||
* @param content - The actual content to include
|
||||
* @param extra - Optional extra info to append to name (e.g., server name for MCP)
|
||||
* @returns Formatted string with header and content
|
||||
*/
|
||||
export function formatAttachmentText(
|
||||
label: string,
|
||||
name: string,
|
||||
content: string,
|
||||
extra?: string
|
||||
): string {
|
||||
const header = extra ? `${name} (${extra})` : name;
|
||||
return `\n\n--- ${label}: ${header} ---\n${content}`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Header utilities for parsing and serializing HTTP headers.
|
||||
* Generic utilities not specific to MCP.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parses a JSON string of headers into an array of key-value pairs.
|
||||
* Returns empty array if the JSON is invalid or empty.
|
||||
*/
|
||||
export function parseHeadersToArray(headersJson: string): { key: string; value: string }[] {
|
||||
if (!headersJson?.trim()) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(headersJson);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return Object.entries(parsed).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value)
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an array of header key-value pairs to a JSON string.
|
||||
* Filters out pairs with empty keys and returns empty string if no valid pairs.
|
||||
*/
|
||||
export function serializeHeaders(pairs: { key: string; value: string }[]): string {
|
||||
const validPairs = pairs.filter((p) => p.key.trim());
|
||||
|
||||
if (validPairs.length === 0) return '';
|
||||
|
||||
const obj: Record<string, string> = {};
|
||||
|
||||
for (const pair of validPairs) {
|
||||
obj[pair.key.trim()] = pair.value;
|
||||
}
|
||||
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Simplified HTML fallback for external images that fail to load.
|
||||
* Displays a centered message with a link to open the image in a new tab.
|
||||
*/
|
||||
export function getImageErrorFallbackHtml(src: string): string {
|
||||
return `<div class="image-error-content">
|
||||
<span>Image cannot be displayed</span>
|
||||
<a href="${src}" target="_blank" rel="noopener noreferrer">(open link)</a>
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Unified exports for all utility functions
|
||||
* Import utilities from '$lib/utils' for cleaner imports
|
||||
*
|
||||
* For browser-only utilities (pdf-processing, audio-recording, svg-to-png,
|
||||
* webp-to-png, process-uploaded-files, convert-files-to-extra), use:
|
||||
* import { ... } from '$lib/utils/browser-only'
|
||||
*/
|
||||
|
||||
// API utilities
|
||||
export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers';
|
||||
export { apiFetch, apiFetchWithParams, apiPost, type ApiFetchOptions } from './api-fetch';
|
||||
export { validateApiKey } from './api-key-validation';
|
||||
|
||||
// Attachment utilities
|
||||
export { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from './attachment-display';
|
||||
export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type';
|
||||
|
||||
// Textarea utilities
|
||||
export { default as autoResizeTextarea } from './autoresize-textarea';
|
||||
|
||||
// Branching utilities
|
||||
export {
|
||||
filterByLeafNodeId,
|
||||
findMessageById,
|
||||
findLeafNode,
|
||||
findDescendantMessages,
|
||||
getMessageSiblings,
|
||||
getMessageDisplayList,
|
||||
hasMessageSiblings,
|
||||
getNextSibling,
|
||||
getPreviousSibling
|
||||
} from './branching';
|
||||
|
||||
// Code
|
||||
export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from './code';
|
||||
|
||||
// Config helpers
|
||||
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
|
||||
|
||||
// CORS Proxy
|
||||
export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy';
|
||||
|
||||
// URL utilities
|
||||
export { extractRootDomain, sanitizeExternalUrl } from './url';
|
||||
|
||||
// Conversation utilities
|
||||
export { createMessageCountMap, getMessageCount } from './conversation-utils';
|
||||
|
||||
// Clipboard utilities
|
||||
export {
|
||||
copyToClipboard,
|
||||
copyCodeToClipboard,
|
||||
formatMessageForClipboard,
|
||||
parseClipboardContent,
|
||||
hasClipboardAttachments
|
||||
} from './clipboard';
|
||||
|
||||
// File preview utilities
|
||||
export { getFileTypeLabel } from './file-preview';
|
||||
export { getPreviewText, generateConversationTitle } from './text';
|
||||
|
||||
// File type utilities
|
||||
export {
|
||||
getFileTypeCategory,
|
||||
getFileTypeCategoryByExtension,
|
||||
getFileTypeByExtension,
|
||||
isFileTypeSupported
|
||||
} from './file-type';
|
||||
|
||||
// Formatting utilities
|
||||
export {
|
||||
formatFileSize,
|
||||
formatParameters,
|
||||
formatNumber,
|
||||
formatJsonPretty,
|
||||
formatTime,
|
||||
formatPerformanceTime,
|
||||
formatAttachmentText
|
||||
} from './formatters';
|
||||
|
||||
// IME utilities
|
||||
export { isIMEComposing } from './is-ime-composing';
|
||||
|
||||
// LaTeX utilities
|
||||
export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection';
|
||||
|
||||
// Modality file validation utilities
|
||||
export {
|
||||
isFileTypeSupportedByModel,
|
||||
filterFilesByModalities,
|
||||
generateModalityErrorMessage
|
||||
} from './modality-file-validation';
|
||||
|
||||
// Model name utilities
|
||||
export { normalizeModelName, isValidModelName } from './model-names';
|
||||
|
||||
// Portal utilities
|
||||
export { portalToBody } from './portal-to-body';
|
||||
|
||||
// Precision utilities
|
||||
export { normalizeFloatingPoint, normalizeNumber } from './precision';
|
||||
|
||||
// Syntax highlighting utilities
|
||||
export { getLanguageFromFilename } from './syntax-highlight-language';
|
||||
|
||||
// Text file utilities
|
||||
export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
|
||||
|
||||
// Debounce utilities
|
||||
export { debounce } from './debounce';
|
||||
|
||||
// Sanitization utilities
|
||||
export { sanitizeKeyValuePairKey, sanitizeKeyValuePairValue } from './sanitize';
|
||||
|
||||
// Image error fallback utilities
|
||||
export { getImageErrorFallbackHtml } from './image-error-fallback';
|
||||
|
||||
// MCP utilities
|
||||
export {
|
||||
detectMcpTransportFromUrl,
|
||||
parseMcpServerSettings,
|
||||
getMcpLogLevelIcon,
|
||||
getMcpLogLevelClass,
|
||||
isImageMimeType,
|
||||
parseResourcePath,
|
||||
getDisplayName,
|
||||
getResourceDisplayName,
|
||||
isCodeResource,
|
||||
isImageResource,
|
||||
getResourceIcon,
|
||||
getResourceTextContent,
|
||||
getResourceBlobContent,
|
||||
downloadResourceContent
|
||||
} from './mcp';
|
||||
|
||||
// URI Template utilities
|
||||
export {
|
||||
extractTemplateVariables,
|
||||
expandTemplate,
|
||||
isTemplateComplete,
|
||||
normalizeResourceUri,
|
||||
type UriTemplateVariable
|
||||
} from './uri-template';
|
||||
|
||||
// Data URL utilities
|
||||
export { createBase64DataUrl } from './data-url';
|
||||
|
||||
// Header utilities
|
||||
export { parseHeadersToArray, serializeHeaders } from './headers';
|
||||
|
||||
// Agentic content utilities (structured section derivation)
|
||||
export {
|
||||
deriveAgenticSections,
|
||||
parseToolResultWithImages,
|
||||
hasAgenticContent,
|
||||
type AgenticSection,
|
||||
type ToolResultLine
|
||||
} from './agentic';
|
||||
|
||||
// Cache utilities
|
||||
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
|
||||
|
||||
// Redaction utilities
|
||||
export { redactValue } from './redact';
|
||||
|
||||
// Request inspection utilities
|
||||
export {
|
||||
getRequestUrl,
|
||||
getRequestMethod,
|
||||
getRequestBody,
|
||||
summarizeRequestBody,
|
||||
formatDiagnosticErrorMessage,
|
||||
extractJsonRpcMethods,
|
||||
type RequestBodySummary
|
||||
} from './request-helpers';
|
||||
|
||||
// Abort signal utilities
|
||||
export {
|
||||
throwIfAborted,
|
||||
isAbortError,
|
||||
createLinkedController,
|
||||
createTimeoutSignal,
|
||||
withAbortSignal
|
||||
} from './abort';
|
||||
|
||||
// Cryptography utilities
|
||||
|
||||
export { uuid } from './uuid';
|
||||
|
||||
// CSS utilities
|
||||
export { remToPx } from './css';
|
||||
@@ -0,0 +1,5 @@
|
||||
export function isIMEComposing(event: KeyboardEvent) {
|
||||
// Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari, which is notorious for not supporting KeyboardEvent.isComposing)
|
||||
// This prevents form submission when confirming IME word selection (e.g., Japanese/Chinese input)
|
||||
return event.isComposing || event.keyCode === 229;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
CODE_BLOCK_REGEXP,
|
||||
LATEX_MATH_AND_CODE_PATTERN,
|
||||
LATEX_LINEBREAK_REGEXP,
|
||||
MHCHEM_PATTERN_MAP
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs
|
||||
* that appear to be part of monetary values or identifiers.
|
||||
*
|
||||
* This function processes the input line by line and skips `$` sequences that are likely
|
||||
* part of money amounts (e.g., `$5`, `$100.99`) or code-like tokens (e.g., `var$`, `$var`).
|
||||
* Valid LaTeX inline math is replaced with a placeholder like `<<LATEX_0>>`, and the
|
||||
* actual LaTeX content is stored in the provided `latexExpressions` array.
|
||||
*
|
||||
* @param content - The input text potentially containing LaTeX expressions.
|
||||
* @param latexExpressions - An array used to collect extracted LaTeX expressions.
|
||||
* @returns The processed string with LaTeX replaced by placeholders.
|
||||
*/
|
||||
export function maskInlineLaTeX(content: string, latexExpressions: string[]): string {
|
||||
if (!content.includes('$')) {
|
||||
return content;
|
||||
}
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (line.indexOf('$') == -1) {
|
||||
return line;
|
||||
}
|
||||
|
||||
let processedLine = '';
|
||||
let currentPosition = 0;
|
||||
|
||||
while (currentPosition < line.length) {
|
||||
const openDollarIndex = line.indexOf('$', currentPosition);
|
||||
|
||||
if (openDollarIndex == -1) {
|
||||
processedLine += line.slice(currentPosition);
|
||||
break;
|
||||
}
|
||||
|
||||
// Is there a next $-sign?
|
||||
const closeDollarIndex = line.indexOf('$', openDollarIndex + 1);
|
||||
|
||||
if (closeDollarIndex == -1) {
|
||||
processedLine += line.slice(currentPosition);
|
||||
break;
|
||||
}
|
||||
|
||||
const charBeforeOpen = openDollarIndex > 0 ? line[openDollarIndex - 1] : '';
|
||||
const charAfterOpen = line[openDollarIndex + 1];
|
||||
const charBeforeClose =
|
||||
openDollarIndex + 1 < closeDollarIndex ? line[closeDollarIndex - 1] : '';
|
||||
const charAfterClose = closeDollarIndex + 1 < line.length ? line[closeDollarIndex + 1] : '';
|
||||
|
||||
let shouldSkipAsNonLatex = false;
|
||||
|
||||
if (closeDollarIndex == currentPosition + 1) {
|
||||
// No content
|
||||
shouldSkipAsNonLatex = true;
|
||||
}
|
||||
|
||||
if (/[A-Za-z0-9_$-]/.test(charBeforeOpen)) {
|
||||
// Character, digit, $, _ or - before first '$', no TeX.
|
||||
shouldSkipAsNonLatex = true;
|
||||
}
|
||||
|
||||
if (
|
||||
/[0-9]/.test(charAfterOpen) &&
|
||||
(/[A-Za-z0-9_$-]/.test(charAfterClose) || ' ' == charBeforeClose)
|
||||
) {
|
||||
// First $ seems to belong to an amount.
|
||||
shouldSkipAsNonLatex = true;
|
||||
}
|
||||
|
||||
if (shouldSkipAsNonLatex) {
|
||||
processedLine += line.slice(currentPosition, openDollarIndex + 1);
|
||||
currentPosition = openDollarIndex + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Treat as LaTeX
|
||||
processedLine += line.slice(currentPosition, openDollarIndex);
|
||||
const latexContent = line.slice(openDollarIndex, closeDollarIndex + 1);
|
||||
latexExpressions.push(latexContent);
|
||||
processedLine += `<<LATEX_${latexExpressions.length - 1}>>`;
|
||||
currentPosition = closeDollarIndex + 1;
|
||||
}
|
||||
|
||||
return processedLine;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function escapeBrackets(text: string): string {
|
||||
return text.replace(
|
||||
LATEX_MATH_AND_CODE_PATTERN,
|
||||
(
|
||||
match: string,
|
||||
codeBlock: string | undefined,
|
||||
squareBracket: string | undefined,
|
||||
roundBracket: string | undefined
|
||||
): string => {
|
||||
if (codeBlock != null) {
|
||||
return codeBlock;
|
||||
} else if (squareBracket != null) {
|
||||
return `$$${squareBracket}$$`;
|
||||
} else if (roundBracket != null) {
|
||||
return `$${roundBracket}$`;
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Escape $\\ce{...} → $\\ce{...} but with proper handling
|
||||
function escapeMhchem(text: string): string {
|
||||
return MHCHEM_PATTERN_MAP.reduce((result, [pattern, replacement]) => {
|
||||
return result.replace(pattern, replacement);
|
||||
}, text);
|
||||
}
|
||||
|
||||
const doEscapeMhchem = false;
|
||||
|
||||
/**
|
||||
* Preprocesses markdown content to safely handle LaTeX math expressions while protecting
|
||||
* against false positives (e.g., dollar amounts like $5.99) and ensuring proper rendering.
|
||||
*
|
||||
* This function:
|
||||
* - Protects code blocks (```) and inline code (`...`)
|
||||
* - Safeguards block and inline LaTeX: \(...\), \[...\], $$...$$, and selective $...$
|
||||
* - Escapes standalone dollar signs before numbers (e.g., $5 → \$5) to prevent misinterpretation
|
||||
* - Restores protected LaTeX and code blocks after processing
|
||||
* - Converts \(...\) → $...$ and \[...\] → $$...$$ for compatibility with math renderers
|
||||
* - Applies additional escaping for brackets and mhchem syntax if needed
|
||||
*
|
||||
* @param content - The raw text (e.g., markdown) that may contain LaTeX or code blocks.
|
||||
* @returns The preprocessed string with properly escaped and normalized LaTeX.
|
||||
*
|
||||
* @example
|
||||
* preprocessLaTeX("Price: $10. The equation is \\(x^2\\).")
|
||||
* // → "Price: $10. The equation is $x^2$."
|
||||
*/
|
||||
export function preprocessLaTeX(content: string): string {
|
||||
// See also:
|
||||
// https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
|
||||
|
||||
// Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly
|
||||
// Store the structure so we can restore it later
|
||||
const blockquoteMarkers: Map<number, string> = new Map();
|
||||
const lines = content.split('\n');
|
||||
const processedLines = lines.map((line, index) => {
|
||||
const match = line.match(/^(>\s*)/);
|
||||
if (match) {
|
||||
blockquoteMarkers.set(index, match[1]);
|
||||
return line.slice(match[1].length);
|
||||
}
|
||||
return line;
|
||||
});
|
||||
content = processedLines.join('\n');
|
||||
|
||||
// Step 1: Protect code blocks
|
||||
const codeBlocks: string[] = [];
|
||||
|
||||
content = content.replace(CODE_BLOCK_REGEXP, (match) => {
|
||||
codeBlocks.push(match);
|
||||
|
||||
return `<<CODE_BLOCK_${codeBlocks.length - 1}>>`;
|
||||
});
|
||||
|
||||
// Step 2: Protect existing LaTeX expressions
|
||||
const latexExpressions: string[] = [];
|
||||
|
||||
// Match \S...\[...\] and protect them and insert a line-break.
|
||||
content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => {
|
||||
// Check if there are characters following the formula (display-formula in a table-cell?)
|
||||
if (group1.endsWith('\\')) {
|
||||
return match; // Backslash before \[, do nothing.
|
||||
}
|
||||
const hasSuffix = /\S/.test(group3);
|
||||
let optBreak;
|
||||
|
||||
if (hasSuffix) {
|
||||
latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
|
||||
optBreak = '';
|
||||
} else {
|
||||
latexExpressions.push(`\\[${group2}\\]`);
|
||||
optBreak = '\n';
|
||||
}
|
||||
|
||||
return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
|
||||
});
|
||||
|
||||
// Match \(...\), \[...\], $$...$$ and protect them
|
||||
content = content.replace(
|
||||
/(\$\$[\s\S]*?\$\$|(?<!\\)\\\[[\s\S]*?\\\]|(?<!\\)\\\(.*?\\\))/g,
|
||||
(match) => {
|
||||
latexExpressions.push(match);
|
||||
|
||||
return `<<LATEX_${latexExpressions.length - 1}>>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Protect inline $...$ but NOT if it looks like money (e.g., $10, $3.99)
|
||||
content = maskInlineLaTeX(content, latexExpressions);
|
||||
|
||||
// Step 3: Escape standalone $ before digits (currency like $5 → \$5)
|
||||
// (Now that inline math is protected, this will only escape dollars not already protected)
|
||||
content = content.replace(/\$(?=\d)/g, '\\$');
|
||||
|
||||
// Step 4: Restore protected LaTeX expressions (they are valid)
|
||||
content = content.replace(/<<LATEX_(\d+)>>/g, (_, index) => {
|
||||
let expr = latexExpressions[parseInt(index)];
|
||||
const match = expr.match(LATEX_LINEBREAK_REGEXP);
|
||||
if (match) {
|
||||
// Katex: The $$-delimiters should be in their own line
|
||||
// if there are \\-line-breaks.
|
||||
const formula = match[1];
|
||||
const prefix = formula.startsWith('\n') ? '' : '\n';
|
||||
const suffix = formula.endsWith('\n') ? '' : '\n';
|
||||
expr = '$$' + prefix + formula + suffix + '$$';
|
||||
}
|
||||
return expr;
|
||||
});
|
||||
|
||||
// Step 5: Apply additional escaping functions (brackets and mhchem)
|
||||
// This must happen BEFORE restoring code blocks to avoid affecting code content
|
||||
content = escapeBrackets(content);
|
||||
|
||||
if (doEscapeMhchem && (content.includes('\\ce{') || content.includes('\\pu{'))) {
|
||||
content = escapeMhchem(content);
|
||||
}
|
||||
|
||||
// Step 6: Convert remaining \(...\) → $...$, \[...\] → $$...$$
|
||||
// This must happen BEFORE restoring code blocks to avoid affecting code content
|
||||
content = content
|
||||
// Using the look‑behind pattern `(?<!\\)` we skip matches
|
||||
// that are preceded by a backslash, e.g.
|
||||
// `Definitions\\(also called macros)` (title of chapter 20 in The TeXbook).
|
||||
.replace(/(?<!\\)\\\((.+?)\\\)/g, '$$$1$') // inline
|
||||
.replace(
|
||||
// Using the look‑behind pattern `(?<!\\)` we skip matches
|
||||
// that are preceded by a backslash, e.g. `\\[4pt]`.
|
||||
/(?<!\\)\\\[([\s\S]*?)\\\]/g, // display, see also PR #16599
|
||||
(_, content: string) => {
|
||||
return `$$${content}$$`;
|
||||
}
|
||||
);
|
||||
|
||||
// Step 7: Restore code blocks
|
||||
// This happens AFTER all LaTeX conversions to preserve code content
|
||||
content = content.replace(/<<CODE_BLOCK_(\d+)>>/g, (_, index) => {
|
||||
return codeBlocks[parseInt(index)];
|
||||
});
|
||||
|
||||
// Step 8: Restore blockquote markers
|
||||
if (blockquoteMarkers.size > 0) {
|
||||
const finalLines = content.split('\n');
|
||||
const restoredLines = finalLines.map((line, index) => {
|
||||
const marker = blockquoteMarkers.get(index);
|
||||
return marker ? marker + line : line;
|
||||
});
|
||||
content = restoredLines.join('\n');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* @deprecated Legacy migration utility — remove at some point in the future once all users have migrated to the new structured agentic message format.
|
||||
*
|
||||
* Converts old marker-based agentic messages to the new structured format
|
||||
* with separate messages per turn.
|
||||
*
|
||||
* Old format: Single assistant message with markers in content:
|
||||
* <<<reasoning_content_start>>>...<<<reasoning_content_end>>>
|
||||
* <<<AGENTIC_TOOL_CALL_START>>>...<<<AGENTIC_TOOL_CALL_END>>>
|
||||
*
|
||||
* New format: Separate messages per turn:
|
||||
* - assistant (content + reasoningContent + toolCalls)
|
||||
* - tool (toolCallId + content)
|
||||
* - assistant (next turn)
|
||||
* - ...
|
||||
*/
|
||||
|
||||
import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
|
||||
const MIGRATION_DONE_KEY = 'llama-ui-migration-v2-done';
|
||||
/** @deprecated Use {@link MIGRATION_DONE_KEY} instead */
|
||||
const DEPRECATED_MIGRATION_DONE_KEY = 'llama-webui-migration-v2-done';
|
||||
|
||||
/**
|
||||
* @deprecated Part of legacy migration — remove with the migration module.
|
||||
* Check if migration has been performed.
|
||||
*/
|
||||
export function isMigrationNeeded(): boolean {
|
||||
try {
|
||||
// Check new key first, fall back to deprecated old key
|
||||
if (localStorage.getItem(MIGRATION_DONE_KEY)) return false;
|
||||
if (localStorage.getItem(DEPRECATED_MIGRATION_DONE_KEY)) {
|
||||
// Migrate to new key
|
||||
try {
|
||||
localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now()));
|
||||
localStorage.removeItem(DEPRECATED_MIGRATION_DONE_KEY);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark migration as done.
|
||||
*/
|
||||
function markMigrationDone(): void {
|
||||
try {
|
||||
localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has legacy markers in its content.
|
||||
*/
|
||||
function hasLegacyMarkers(message: DatabaseMessage): boolean {
|
||||
if (!message.content) return false;
|
||||
return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(message.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract reasoning content from legacy marker format.
|
||||
*/
|
||||
function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } {
|
||||
let reasoning = '';
|
||||
let cleanContent = content;
|
||||
|
||||
// Extract all reasoning blocks
|
||||
const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
|
||||
let match;
|
||||
while ((match = re.exec(content)) !== null) {
|
||||
reasoning += match[1];
|
||||
}
|
||||
|
||||
// Remove reasoning tags from content
|
||||
cleanContent = cleanContent
|
||||
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
|
||||
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
|
||||
|
||||
return { reasoning, cleanContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse legacy content with tool call markers into structured turns.
|
||||
*/
|
||||
interface ParsedTurn {
|
||||
textBefore: string;
|
||||
toolCalls: Array<{
|
||||
name: string;
|
||||
args: string;
|
||||
result: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseLegacyToolCalls(content: string): ParsedTurn[] {
|
||||
const turns: ParsedTurn[] = [];
|
||||
const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
|
||||
|
||||
let lastIndex = 0;
|
||||
let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] };
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const textBefore = content.slice(lastIndex, match.index).trim();
|
||||
|
||||
// If there's text between tool calls and we already have tool calls,
|
||||
// that means a new turn started (text after tool results = new LLM turn)
|
||||
if (textBefore && currentTurn.toolCalls.length > 0) {
|
||||
turns.push(currentTurn);
|
||||
currentTurn = { textBefore, toolCalls: [] };
|
||||
} else if (textBefore && currentTurn.toolCalls.length === 0) {
|
||||
currentTurn.textBefore = textBefore;
|
||||
}
|
||||
|
||||
currentTurn.toolCalls.push({
|
||||
name: match[1],
|
||||
args: match[2],
|
||||
result: match[3].replace(/^\n+|\n+$/g, '')
|
||||
});
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Any remaining text after the last tool call
|
||||
const remainingText = content.slice(lastIndex).trim();
|
||||
|
||||
if (currentTurn.toolCalls.length > 0) {
|
||||
turns.push(currentTurn);
|
||||
}
|
||||
|
||||
// If there's text after all tool calls, it's the final assistant response
|
||||
if (remainingText) {
|
||||
// Remove any partial/open markers
|
||||
const cleanRemaining = remainingText
|
||||
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
|
||||
.trim();
|
||||
if (cleanRemaining) {
|
||||
turns.push({ textBefore: cleanRemaining, toolCalls: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// If no tool calls found at all, return the original content as a single turn
|
||||
if (turns.length === 0) {
|
||||
turns.push({ textBefore: content.trim(), toolCalls: [] });
|
||||
}
|
||||
|
||||
return turns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single conversation's messages from legacy format to new format.
|
||||
*/
|
||||
async function migrateConversation(convId: string): Promise<number> {
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
let migratedCount = 0;
|
||||
|
||||
for (const message of allMessages) {
|
||||
if (message.role !== MessageRole.ASSISTANT) continue;
|
||||
if (!hasLegacyMarkers(message)) {
|
||||
// Still check for reasoning-only markers (no tool calls)
|
||||
if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
|
||||
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
|
||||
await DatabaseService.updateMessage(message.id, {
|
||||
content: cleanContent.trim(),
|
||||
reasoningContent: reasoning || undefined
|
||||
});
|
||||
migratedCount++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Has agentic markers - full migration needed
|
||||
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
|
||||
const turns = parseLegacyToolCalls(cleanContent);
|
||||
|
||||
// Parse existing toolCalls JSON to try to match IDs
|
||||
let existingToolCalls: Array<{
|
||||
id: string;
|
||||
function?: { name: string; arguments: string };
|
||||
}> = [];
|
||||
if (message.toolCalls) {
|
||||
try {
|
||||
existingToolCalls = JSON.parse(message.toolCalls);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// First turn uses the existing message
|
||||
const firstTurn = turns[0];
|
||||
if (!firstTurn) continue;
|
||||
|
||||
// Match tool calls from the first turn to existing IDs
|
||||
const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
|
||||
const existing =
|
||||
existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
|
||||
return {
|
||||
id: existing?.id || `legacy_tool_${i}`,
|
||||
type: 'function' as const,
|
||||
function: { name: tc.name, arguments: tc.args }
|
||||
};
|
||||
});
|
||||
|
||||
// Update the existing message for the first turn
|
||||
await DatabaseService.updateMessage(message.id, {
|
||||
content: firstTurn.textBefore,
|
||||
reasoningContent: reasoning || undefined,
|
||||
toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : ''
|
||||
});
|
||||
|
||||
let currentParentId = message.id;
|
||||
let toolCallIdCounter = existingToolCalls.length;
|
||||
|
||||
// Create tool result messages for the first turn
|
||||
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 DatabaseService.createMessageBranch(
|
||||
{
|
||||
convId,
|
||||
type: MessageType.TEXT,
|
||||
role: MessageRole.TOOL,
|
||||
content: tc.result,
|
||||
toolCallId,
|
||||
timestamp: message.timestamp + i + 1,
|
||||
toolCalls: '',
|
||||
children: []
|
||||
},
|
||||
currentParentId
|
||||
);
|
||||
currentParentId = toolMsg.id;
|
||||
}
|
||||
|
||||
// Create messages for subsequent turns
|
||||
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 {
|
||||
id: existing?.id || `legacy_tool_${idx}`,
|
||||
type: 'function' as const,
|
||||
function: { name: tc.name, arguments: tc.args }
|
||||
};
|
||||
});
|
||||
toolCallIdCounter += turn.toolCalls.length;
|
||||
|
||||
// Create assistant message for this turn
|
||||
const assistantMsg = await DatabaseService.createMessageBranch(
|
||||
{
|
||||
convId,
|
||||
type: MessageType.TEXT,
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: turn.textBefore,
|
||||
timestamp: message.timestamp + turnIdx * 100,
|
||||
toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
|
||||
children: [],
|
||||
model: message.model
|
||||
},
|
||||
currentParentId
|
||||
);
|
||||
currentParentId = assistantMsg.id;
|
||||
|
||||
// Create tool result messages for this turn
|
||||
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 DatabaseService.createMessageBranch(
|
||||
{
|
||||
convId,
|
||||
type: MessageType.TEXT,
|
||||
role: MessageRole.TOOL,
|
||||
content: tc.result,
|
||||
toolCallId,
|
||||
timestamp: message.timestamp + turnIdx * 100 + i + 1,
|
||||
toolCalls: '',
|
||||
children: []
|
||||
},
|
||||
currentParentId
|
||||
);
|
||||
currentParentId = toolMsg.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-parent any children of the original message to the last created message
|
||||
// (the original message's children list was the next user message or similar)
|
||||
if (message.children.length > 0 && currentParentId !== message.id) {
|
||||
for (const childId of message.children) {
|
||||
// Skip children we just created (they were already properly parented)
|
||||
const child = allMessages.find((m) => m.id === childId);
|
||||
if (!child) continue;
|
||||
// Only re-parent non-tool messages that were original children
|
||||
if (child.role !== MessageRole.TOOL) {
|
||||
await DatabaseService.updateMessage(childId, { parent: currentParentId });
|
||||
// Add to new parent's children
|
||||
const newParent = await DatabaseService.getConversationMessages(convId).then((msgs) =>
|
||||
msgs.find((m) => m.id === currentParentId)
|
||||
);
|
||||
if (newParent && !newParent.children.includes(childId)) {
|
||||
await DatabaseService.updateMessage(currentParentId, {
|
||||
children: [...newParent.children, childId]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear re-parented children from the original message
|
||||
await DatabaseService.updateMessage(message.id, { children: [] });
|
||||
}
|
||||
|
||||
migratedCount++;
|
||||
}
|
||||
|
||||
return migratedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Part of legacy migration — remove with the migration module.
|
||||
* Run the full migration across all conversations.
|
||||
* This should be called once at app startup if migration is needed.
|
||||
*/
|
||||
export async function runLegacyMigration(): Promise<void> {
|
||||
if (!isMigrationNeeded()) return;
|
||||
|
||||
console.log('[Migration] Starting legacy message format migration...');
|
||||
|
||||
try {
|
||||
const conversations = await DatabaseService.getAllConversations();
|
||||
let totalMigrated = 0;
|
||||
|
||||
for (const conv of conversations) {
|
||||
const count = await migrateConversation(conv.id);
|
||||
totalMigrated += count;
|
||||
}
|
||||
|
||||
if (totalMigrated > 0) {
|
||||
console.log(
|
||||
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
|
||||
);
|
||||
} else {
|
||||
console.log('[Migration] No legacy messages found, marking as done');
|
||||
}
|
||||
|
||||
markMigrationDone();
|
||||
} catch (error) {
|
||||
console.error('[Migration] Failed to migrate legacy messages:', error);
|
||||
// Still mark as done to avoid infinite retry loops
|
||||
markMigrationDone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { MCPServerSettingsEntry, MCPResourceContent, MCPResourceInfo } from '$lib/types';
|
||||
import {
|
||||
MCPTransportType,
|
||||
MCPLogLevel,
|
||||
UrlProtocol,
|
||||
MimeTypePrefix,
|
||||
MimeTypeIncludes,
|
||||
UriPattern,
|
||||
MimeTypeText
|
||||
} from '$lib/enums';
|
||||
import {
|
||||
DEFAULT_MCP_CONFIG,
|
||||
MCP_SERVER_ID_PREFIX,
|
||||
IMAGE_FILE_EXTENSION_REGEX,
|
||||
CODE_FILE_EXTENSION_REGEX,
|
||||
TEXT_FILE_EXTENSION_REGEX,
|
||||
PROTOCOL_PREFIX_REGEX,
|
||||
FILE_EXTENSION_REGEX,
|
||||
DISPLAY_NAME_SEPARATOR_REGEX,
|
||||
PATH_SEPARATOR,
|
||||
RESOURCE_TEXT_CONTENT_SEPARATOR,
|
||||
DEFAULT_RESOURCE_FILENAME
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
Database,
|
||||
File,
|
||||
FileText,
|
||||
Image,
|
||||
Code,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
XCircle
|
||||
} from '@lucide/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
import type { MimeTypeUnion } from '$lib/types/common';
|
||||
|
||||
/**
|
||||
* Detects the MCP transport type from a URL.
|
||||
* WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'.
|
||||
*/
|
||||
export function detectMcpTransportFromUrl(url: string): MCPTransportType {
|
||||
const normalized = url.trim().toLowerCase();
|
||||
|
||||
return normalized.startsWith(UrlProtocol.WEBSOCKET) ||
|
||||
normalized.startsWith(UrlProtocol.WEBSOCKET_SECURE)
|
||||
? MCPTransportType.WEBSOCKET
|
||||
: MCPTransportType.STREAMABLE_HTTP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses MCP server settings from a JSON string or array.
|
||||
* requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
|
||||
* @param rawServers - The raw servers to parse
|
||||
* @returns An empty array if the input is invalid.
|
||||
*/
|
||||
export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
|
||||
if (!rawServers) return [];
|
||||
|
||||
let parsed: unknown;
|
||||
|
||||
if (typeof rawServers === 'string') {
|
||||
const trimmed = rawServers.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
|
||||
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
parsed = rawServers;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.map((entry, index) => {
|
||||
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
|
||||
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
|
||||
const id =
|
||||
typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim()
|
||||
? (entry as { id: string }).id.trim()
|
||||
: `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||
url,
|
||||
name: (entry as { name?: string })?.name,
|
||||
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
headers: headers || undefined,
|
||||
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
||||
} satisfies MCPServerSettingsEntry;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate icon component for a log level
|
||||
*
|
||||
* @param level - MCP log level
|
||||
* @returns Lucide icon component
|
||||
*/
|
||||
export function getMcpLogLevelIcon(level: MCPLogLevel): Component {
|
||||
switch (level) {
|
||||
case MCPLogLevel.ERROR:
|
||||
return XCircle;
|
||||
case MCPLogLevel.WARN:
|
||||
return AlertTriangle;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate CSS class for a log level
|
||||
*
|
||||
* @param level - MCP log level
|
||||
* @returns Tailwind CSS class string
|
||||
*/
|
||||
export function getMcpLogLevelClass(level: MCPLogLevel): string {
|
||||
switch (level) {
|
||||
case MCPLogLevel.ERROR:
|
||||
return 'text-destructive';
|
||||
case MCPLogLevel.WARN:
|
||||
return 'text-yellow-600 dark:text-yellow-500';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents an image.
|
||||
*
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if the MIME type starts with 'image/'
|
||||
*/
|
||||
export function isImageMimeType(mimeType?: MimeTypeUnion): boolean {
|
||||
return mimeType?.startsWith(MimeTypePrefix.IMAGE) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a resource URI into path segments, stripping the protocol prefix.
|
||||
*
|
||||
* @param uri - The resource URI to parse
|
||||
* @returns Array of non-empty path segments
|
||||
*/
|
||||
export function parseResourcePath(uri: string): string[] {
|
||||
try {
|
||||
const withoutProtocol = uri.replace(PROTOCOL_PREFIX_REGEX, '');
|
||||
return withoutProtocol.split(PATH_SEPARATOR).filter((p) => p.length > 0);
|
||||
} catch {
|
||||
return [uri];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a path part into a human-readable display name.
|
||||
* Strips file extensions and converts kebab-case/snake_case to Title Case.
|
||||
*
|
||||
* @param pathPart - The path segment to convert
|
||||
* @returns Human-readable display name
|
||||
*/
|
||||
export function getDisplayName(pathPart: string): string {
|
||||
const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, '');
|
||||
return withoutExt
|
||||
.split(DISPLAY_NAME_SEPARATOR_REGEX)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name from a resource, extracting the last path segment from the URI.
|
||||
*
|
||||
* @param resource - The MCP resource info
|
||||
* @returns Display name string
|
||||
*/
|
||||
export function getResourceDisplayName(resource: MCPResourceInfo): string {
|
||||
try {
|
||||
const parts = parseResourcePath(resource.uri);
|
||||
return parts[parts.length - 1] || resource.name || resource.uri;
|
||||
} catch {
|
||||
return resource.name || resource.uri;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a MIME type and/or URI represents code content.
|
||||
*
|
||||
* @param mimeType - Optional MIME type string
|
||||
* @param uri - Optional URI string
|
||||
* @returns True if the content is code
|
||||
*/
|
||||
export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
return (
|
||||
mime.includes(MimeTypeIncludes.JSON) ||
|
||||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
|
||||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
|
||||
CODE_FILE_EXTENSION_REGEX.test(u)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a MIME type and/or URI represents image content.
|
||||
*
|
||||
* @param mimeType - Optional MIME type string
|
||||
* @param uri - Optional URI string
|
||||
* @returns True if the content is an image
|
||||
*/
|
||||
export function isImageResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
return mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate Lucide icon component for an MCP resource based on its MIME type and URI.
|
||||
*
|
||||
* @param mimeType - Optional MIME type of the resource
|
||||
* @param uri - Optional URI of the resource
|
||||
* @returns Lucide icon component
|
||||
*/
|
||||
export function getResourceIcon(mimeType?: MimeTypeUnion, uri?: string): Component {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
|
||||
if (mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u)) {
|
||||
return Image;
|
||||
}
|
||||
|
||||
if (
|
||||
mime.includes(MimeTypeIncludes.JSON) ||
|
||||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
|
||||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
|
||||
CODE_FILE_EXTENSION_REGEX.test(u)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
|
||||
if (mime.includes(MimeTypePrefix.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(u)) {
|
||||
return FileText;
|
||||
}
|
||||
|
||||
if (u.includes(UriPattern.DATABASE_KEYWORD) || u.includes(UriPattern.DATABASE_SCHEME)) {
|
||||
return Database;
|
||||
}
|
||||
|
||||
return File;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from MCP resource content array.
|
||||
*
|
||||
* @param content - Array of MCP resource content items
|
||||
* @returns Joined text content string
|
||||
*/
|
||||
export function getResourceTextContent(content: MCPResourceContent[] | null | undefined): string {
|
||||
if (!content) return '';
|
||||
return content
|
||||
.filter((c): c is { uri: string; mimeType?: MimeTypeUnion; text: string } => 'text' in c)
|
||||
.map((c) => c.text)
|
||||
.join(RESOURCE_TEXT_CONTENT_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract blob content from MCP resource content array.
|
||||
*
|
||||
* @param content - Array of MCP resource content items
|
||||
* @returns Array of blob content items
|
||||
*/
|
||||
export function getResourceBlobContent(
|
||||
content: MCPResourceContent[] | null | undefined
|
||||
): Array<{ uri: string; mimeType?: MimeTypeUnion; blob: string }> {
|
||||
if (!content) return [];
|
||||
|
||||
return content.filter(
|
||||
(c): c is { uri: string; mimeType?: MimeTypeUnion; blob: string } => 'blob' in c
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download from text content.
|
||||
*
|
||||
* @param text - The text content to download
|
||||
* @param mimeType - MIME type for the blob
|
||||
* @param filename - Suggested filename
|
||||
*/
|
||||
export function downloadResourceContent(
|
||||
text: string,
|
||||
mimeType: MimeTypeUnion = MimeTypeText.PLAIN,
|
||||
filename: string = DEFAULT_RESOURCE_FILENAME
|
||||
): void {
|
||||
const blob = new Blob([text], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* File validation utilities based on model modalities
|
||||
* Ensures only compatible file types are processed based on model capabilities
|
||||
*/
|
||||
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
import type { ModalityCapabilities } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Check if a file type is supported by the given modalities
|
||||
* @param filename - The filename to check
|
||||
* @param mimeType - The MIME type of the file
|
||||
* @param capabilities - The modality capabilities to check against
|
||||
* @returns true if the file type is supported
|
||||
*/
|
||||
export function isFileTypeSupportedByModel(
|
||||
filename: string,
|
||||
mimeType: string | undefined,
|
||||
capabilities: ModalityCapabilities
|
||||
): boolean {
|
||||
const category = mimeType ? getFileTypeCategory(mimeType) : null;
|
||||
|
||||
// If we can't determine the category from MIME type, fall back to general support check
|
||||
if (!category) {
|
||||
// For unknown types, only allow if they might be text files
|
||||
// This is a conservative approach for edge cases
|
||||
return true; // Let the existing isFileTypeSupported handle this
|
||||
}
|
||||
|
||||
switch (category) {
|
||||
case FileTypeCategory.TEXT:
|
||||
// Text files are always supported
|
||||
return true;
|
||||
|
||||
case FileTypeCategory.PDF:
|
||||
// PDFs are always supported (will be processed as text for non-vision models)
|
||||
return true;
|
||||
|
||||
case FileTypeCategory.IMAGE:
|
||||
// Images require vision support
|
||||
return capabilities.hasVision;
|
||||
|
||||
case FileTypeCategory.AUDIO:
|
||||
// Audio files require audio support
|
||||
return capabilities.hasAudio;
|
||||
|
||||
default:
|
||||
// Unknown categories - be conservative and allow
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter files based on model modalities and return supported/unsupported lists
|
||||
* @param files - Array of files to filter
|
||||
* @param capabilities - The modality capabilities to check against
|
||||
* @returns Object with supportedFiles and unsupportedFiles arrays
|
||||
*/
|
||||
export function filterFilesByModalities(
|
||||
files: File[],
|
||||
capabilities: ModalityCapabilities
|
||||
): {
|
||||
supportedFiles: File[];
|
||||
unsupportedFiles: File[];
|
||||
modalityReasons: Record<string, string>;
|
||||
} {
|
||||
const supportedFiles: File[] = [];
|
||||
const unsupportedFiles: File[] = [];
|
||||
const modalityReasons: Record<string, string> = {};
|
||||
|
||||
const { hasVision, hasAudio } = capabilities;
|
||||
|
||||
for (const file of files) {
|
||||
const category = getFileTypeCategory(file.type);
|
||||
let isSupported = true;
|
||||
let reason = '';
|
||||
|
||||
switch (category) {
|
||||
case FileTypeCategory.IMAGE:
|
||||
if (!hasVision) {
|
||||
isSupported = false;
|
||||
reason = 'Images require a vision-capable model';
|
||||
}
|
||||
break;
|
||||
|
||||
case FileTypeCategory.AUDIO:
|
||||
if (!hasAudio) {
|
||||
isSupported = false;
|
||||
reason = 'Audio files require an audio-capable model';
|
||||
}
|
||||
break;
|
||||
|
||||
case FileTypeCategory.TEXT:
|
||||
case FileTypeCategory.PDF:
|
||||
// Always supported
|
||||
break;
|
||||
|
||||
default:
|
||||
// For unknown types, check if it's a generally supported file type
|
||||
// This handles edge cases and maintains backward compatibility
|
||||
break;
|
||||
}
|
||||
|
||||
if (isSupported) {
|
||||
supportedFiles.push(file);
|
||||
} else {
|
||||
unsupportedFiles.push(file);
|
||||
modalityReasons[file.name] = reason;
|
||||
}
|
||||
}
|
||||
|
||||
return { supportedFiles, unsupportedFiles, modalityReasons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a user-friendly error message for unsupported files
|
||||
* @param unsupportedFiles - Array of unsupported files
|
||||
* @param modalityReasons - Reasons why files are unsupported
|
||||
* @param capabilities - The modality capabilities to check against
|
||||
* @returns Formatted error message
|
||||
*/
|
||||
export function generateModalityErrorMessage(
|
||||
unsupportedFiles: File[],
|
||||
modalityReasons: Record<string, string>,
|
||||
capabilities: ModalityCapabilities
|
||||
): string {
|
||||
if (unsupportedFiles.length === 0) return '';
|
||||
|
||||
const { hasVision, hasAudio } = capabilities;
|
||||
|
||||
let message = '';
|
||||
|
||||
if (unsupportedFiles.length === 1) {
|
||||
const file = unsupportedFiles[0];
|
||||
const reason = modalityReasons[file.name];
|
||||
message = `The file "${file.name}" cannot be uploaded: ${reason}.`;
|
||||
} else {
|
||||
const fileNames = unsupportedFiles.map((f) => f.name).join(', ');
|
||||
message = `The following files cannot be uploaded: ${fileNames}.`;
|
||||
}
|
||||
|
||||
// Add helpful information about what is supported
|
||||
const supportedTypes: string[] = ['text files', 'PDFs'];
|
||||
if (hasVision) supportedTypes.push('images');
|
||||
if (hasAudio) supportedTypes.push('audio files');
|
||||
|
||||
message += ` This model supports: ${supportedTypes.join(', ')}.`;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate file input accept string based on model modalities
|
||||
* @param capabilities - The modality capabilities to check against
|
||||
* @returns Accept string for HTML file input element
|
||||
*/
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format.
|
||||
*
|
||||
* Handles both forward slashes (/) and backslashes (\) as path separators.
|
||||
* - If the model name has exactly one slash (org/model format), preserves the full "org/model" name
|
||||
* - If the model name has no slash or multiple slashes, extracts just the filename
|
||||
* - If the model name is just a filename (no path), returns it as-is.
|
||||
*
|
||||
* @param modelName - The model name or path to normalize
|
||||
* @returns The normalized model name
|
||||
*
|
||||
* @example
|
||||
* normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b' (multiple slashes -> filename)
|
||||
* normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4' (multiple slashes -> filename)
|
||||
* normalizeModelName('meta-llama/Llama-3.1-8B') // Returns: 'meta-llama/Llama-3.1-8B' (Hugging Face format)
|
||||
* normalizeModelName('simple-model') // Returns: 'simple-model' (no slash)
|
||||
* normalizeModelName(' spaced ') // Returns: 'spaced'
|
||||
* normalizeModelName('') // Returns: ''
|
||||
*/
|
||||
export function normalizeModelName(modelName: string): string {
|
||||
const trimmed = modelName.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const segments = trimmed.split(/[\\/]/);
|
||||
|
||||
// If we have exactly 2 segments (one slash), treat it as Hugging Face repo format
|
||||
// and preserve the full "org/model" format
|
||||
if (segments.length === 2) {
|
||||
const [org, model] = segments;
|
||||
const trimmedOrg = org?.trim();
|
||||
const trimmedModel = model?.trim();
|
||||
|
||||
if (trimmedOrg && trimmedModel) {
|
||||
return `${trimmedOrg}/${trimmedModel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// For other cases (no slash, or multiple slashes), extract just the filename
|
||||
const candidate = segments.pop();
|
||||
const normalized = candidate?.trim();
|
||||
|
||||
return normalized && normalized.length > 0 ? normalized : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a model name is valid (non-empty after normalization).
|
||||
*
|
||||
* @param modelName - The model name to validate
|
||||
* @returns true if valid, false otherwise
|
||||
*/
|
||||
export function isValidModelName(modelName: string): boolean {
|
||||
return normalizeModelName(modelName).length > 0;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* PDF processing utilities using PDF.js
|
||||
* Handles PDF text extraction and image conversion in the browser
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { MimeTypeApplication, MimeTypeImage } from '$lib/enums';
|
||||
import * as pdfjs from 'pdfjs-dist';
|
||||
|
||||
type TextContent = {
|
||||
items: Array<{ str: string }>;
|
||||
};
|
||||
|
||||
if (browser) {
|
||||
// Import worker as text and create blob URL for inline bundling
|
||||
import('pdfjs-dist/build/pdf.worker.min.mjs?raw')
|
||||
.then((workerModule) => {
|
||||
const workerBlob = new Blob([workerModule.default], { type: 'application/javascript' });
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(workerBlob);
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn('Failed to load PDF.js worker, PDF processing may not work');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File object to ArrayBuffer for PDF.js processing
|
||||
* @param file - The PDF file to convert
|
||||
* @returns Promise resolving to the file's ArrayBuffer
|
||||
*/
|
||||
async function getFileAsBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result) {
|
||||
resolve(event.target.result as ArrayBuffer);
|
||||
} else {
|
||||
reject(new Error('Failed to read file.'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Failed to read file.'));
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from a PDF file
|
||||
* @param file - The PDF file to process
|
||||
* @returns Promise resolving to the extracted text content
|
||||
*/
|
||||
export async function convertPDFToText(file: File): Promise<string> {
|
||||
if (!browser) {
|
||||
throw new Error('PDF processing is only available in the browser');
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await getFileAsBuffer(file);
|
||||
const pdf = await pdfjs.getDocument(buffer).promise;
|
||||
const numPages = pdf.numPages;
|
||||
|
||||
const textContentPromises: Promise<TextContent>[] = [];
|
||||
|
||||
for (let i = 1; i <= numPages; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
textContentPromises.push(pdf.getPage(i).then((page: any) => page.getTextContent()));
|
||||
}
|
||||
|
||||
const textContents = await Promise.all(textContentPromises);
|
||||
const textItems = textContents.flatMap((textContent: TextContent) =>
|
||||
textContent.items.map((item) => item.str ?? '')
|
||||
);
|
||||
|
||||
return textItems.join('\n');
|
||||
} catch (error) {
|
||||
console.error('Error converting PDF to text:', error);
|
||||
throw new Error(
|
||||
`Failed to convert PDF to text: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PDF pages to PNG images as data URLs
|
||||
* @param file - The PDF file to convert
|
||||
* @param scale - Rendering scale factor (default: 1.5)
|
||||
* @returns Promise resolving to array of PNG data URLs
|
||||
*/
|
||||
export async function convertPDFToImage(file: File, scale: number = 1.5): Promise<string[]> {
|
||||
if (!browser) {
|
||||
throw new Error('PDF processing is only available in the browser');
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await getFileAsBuffer(file);
|
||||
const doc = await pdfjs.getDocument(buffer).promise;
|
||||
const pages: Promise<string>[] = [];
|
||||
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get 2D context from canvas');
|
||||
}
|
||||
|
||||
const task = page.render({
|
||||
canvasContext: ctx,
|
||||
viewport: viewport,
|
||||
canvas: canvas
|
||||
});
|
||||
pages.push(
|
||||
task.promise.then(() => {
|
||||
return canvas.toDataURL(MimeTypeImage.PNG);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return await Promise.all(pages);
|
||||
} catch (error) {
|
||||
console.error('Error converting PDF to images:', error);
|
||||
throw new Error(
|
||||
`Failed to convert PDF to images: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a PDF based on its MIME type
|
||||
* @param file - The file to check
|
||||
* @returns True if the file is a PDF
|
||||
*/
|
||||
export function isPdfFile(file: File): boolean {
|
||||
return file.type === MimeTypeApplication.PDF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents a PDF
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if the MIME type is application/pdf
|
||||
*/
|
||||
export function isApplicationMimeType(mimeType: string): boolean {
|
||||
return mimeType === MimeTypeApplication.PDF;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function portalToBody(node: HTMLElement) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = document.body;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.appendChild(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
if (node.parentNode === target) {
|
||||
target.removeChild(node);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Floating-point precision utilities
|
||||
*
|
||||
* Provides functions to normalize floating-point numbers for consistent comparison
|
||||
* and display, addressing JavaScript's floating-point precision issues.
|
||||
*/
|
||||
|
||||
import { PRECISION_MULTIPLIER } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Normalize floating-point numbers for consistent comparison
|
||||
* Addresses JavaScript floating-point precision issues (e.g., 0.949999988079071 → 0.95)
|
||||
*/
|
||||
export function normalizeFloatingPoint(value: unknown): unknown {
|
||||
return typeof value === 'number'
|
||||
? Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER
|
||||
: value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe version that only accepts numbers
|
||||
*/
|
||||
export function normalizeNumber(value: number): number {
|
||||
return Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
|
||||
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
import { convertPDFToText } from './pdf-processing';
|
||||
|
||||
/**
|
||||
* Read a file as a data URL (base64 encoded)
|
||||
* @param file - The file to read
|
||||
* @returns Promise resolving to the data URL string
|
||||
*/
|
||||
function readFileAsDataURL(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file as UTF-8 text
|
||||
* @param file - The file to read
|
||||
* @returns Promise resolving to the text content
|
||||
*/
|
||||
function readFileAsUTF8(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process uploaded files into ChatUploadedFile format with previews and content
|
||||
*
|
||||
* This function processes various file types and generates appropriate previews:
|
||||
* - Images: Base64 data URLs with format normalization (SVG/WebP → PNG)
|
||||
* - Text files: UTF-8 content extraction
|
||||
* - PDFs: Metadata only (processed later in conversion pipeline)
|
||||
* - Audio: Base64 data URLs for preview
|
||||
*
|
||||
* @param files - Array of File objects to process
|
||||
* @returns Promise resolving to array of ChatUploadedFile objects
|
||||
*/
|
||||
export async function processFilesToChatUploaded(
|
||||
files: File[],
|
||||
activeModelId?: string
|
||||
): Promise<ChatUploadedFile[]> {
|
||||
const results: ChatUploadedFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
const base: ChatUploadedFile = {
|
||||
id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
file
|
||||
};
|
||||
|
||||
try {
|
||||
if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
|
||||
let preview = await readFileAsDataURL(file);
|
||||
|
||||
// Normalize SVG and WebP to PNG in previews
|
||||
if (isSvgMimeType(file.type)) {
|
||||
try {
|
||||
preview = await svgBase64UrlToPngDataURL(preview);
|
||||
} catch (err) {
|
||||
console.error('Failed to convert SVG to PNG:', err);
|
||||
}
|
||||
} else if (isWebpMimeType(file.type)) {
|
||||
try {
|
||||
preview = await webpBase64UrlToPngDataURL(preview);
|
||||
} catch (err) {
|
||||
console.error('Failed to convert WebP to PNG:', err);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ ...base, preview });
|
||||
} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
|
||||
// Extract text content from PDF for preview
|
||||
try {
|
||||
const textContent = await convertPDFToText(file);
|
||||
results.push({ ...base, textContent });
|
||||
} catch (err) {
|
||||
console.warn('Failed to extract text from PDF, adding without content:', err);
|
||||
results.push(base);
|
||||
}
|
||||
|
||||
// Show suggestion toast if vision model is available but PDF as image is disabled
|
||||
const hasVisionSupport = activeModelId
|
||||
? modelsStore.modelSupportsVision(activeModelId)
|
||||
: false;
|
||||
const currentConfig = settingsStore.config;
|
||||
if (hasVisionSupport && !currentConfig.pdfAsImage) {
|
||||
toast.info(`You can enable parsing PDF as images with vision models.`, {
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: 'Enable PDF as Images',
|
||||
onClick: () => {
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, true);
|
||||
toast.success('PDF parsing as images enabled!', {
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
|
||||
// Generate preview URL for audio files
|
||||
const preview = await readFileAsDataURL(file);
|
||||
results.push({ ...base, preview });
|
||||
} else {
|
||||
// Fallback: treat unknown files as text
|
||||
try {
|
||||
const textContent = await readFileAsUTF8(file);
|
||||
results.push({ ...base, textContent });
|
||||
} catch (err) {
|
||||
console.warn('Failed to read file as text, adding without content:', err);
|
||||
results.push(base);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing file', file.name, error);
|
||||
results.push(base);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Redacts a sensitive value, optionally showing the last N characters.
|
||||
*
|
||||
* @param value - The value to redact
|
||||
* @param showLastChars - If provided, reveals the last N characters with a leading mask
|
||||
* @returns The redacted string
|
||||
*/
|
||||
export function redactValue(value: string, showLastChars?: number): string {
|
||||
if (showLastChars) {
|
||||
return `....${value.slice(-showLastChars)}`;
|
||||
}
|
||||
|
||||
return '[redacted]';
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* HTTP request inspection utilities for diagnostic logging.
|
||||
* These helpers extract metadata from fetch-style request arguments
|
||||
* without exposing sensitive payload data.
|
||||
*/
|
||||
|
||||
export interface RequestBodySummary {
|
||||
kind: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function getRequestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
|
||||
return input.url;
|
||||
}
|
||||
|
||||
export function getRequestMethod(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
baseInit?: RequestInit
|
||||
): string {
|
||||
if (init?.method) {
|
||||
return init.method;
|
||||
}
|
||||
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
return input.method;
|
||||
}
|
||||
|
||||
return baseInit?.method ?? 'GET';
|
||||
}
|
||||
|
||||
export function getRequestBody(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): BodyInit | null | undefined {
|
||||
if (init?.body !== undefined) {
|
||||
return init.body;
|
||||
}
|
||||
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
return input.body;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function summarizeRequestBody(body: BodyInit | null | undefined): RequestBodySummary {
|
||||
if (body == null) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return { kind: 'string', size: body.length };
|
||||
}
|
||||
|
||||
if (body instanceof Blob) {
|
||||
return { kind: 'blob', size: body.size };
|
||||
}
|
||||
|
||||
if (body instanceof URLSearchParams) {
|
||||
return { kind: 'urlsearchparams', size: body.toString().length };
|
||||
}
|
||||
|
||||
if (body instanceof FormData) {
|
||||
return { kind: 'formdata' };
|
||||
}
|
||||
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return { kind: 'arraybuffer', size: body.byteLength };
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
return { kind: body.constructor.name, size: body.byteLength };
|
||||
}
|
||||
|
||||
return { kind: typeof body };
|
||||
}
|
||||
|
||||
export function formatDiagnosticErrorMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return message.includes('Failed to fetch') ? `${message} (check CORS?)` : message;
|
||||
}
|
||||
|
||||
export function extractJsonRpcMethods(body: BodyInit | null | undefined): string[] | undefined {
|
||||
if (typeof body !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const methods = messages
|
||||
.map((message: Record<string, unknown>) =>
|
||||
typeof message?.method === 'string' ? (message.method as string) : undefined
|
||||
)
|
||||
.filter((method: string | undefined): method is string => Boolean(method));
|
||||
|
||||
return methods.length > 0 ? methods : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
KEY_VALUE_PAIR_KEY_MAX_LENGTH,
|
||||
KEY_VALUE_PAIR_VALUE_MAX_LENGTH,
|
||||
KEY_VALUE_PAIR_UNSAFE_KEY_RE,
|
||||
KEY_VALUE_PAIR_UNSAFE_VALUE_RE
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Strip control characters unsafe in identifier/header-name contexts and cap length.
|
||||
* Removes all C0 controls (including TAB) and DEL.
|
||||
*/
|
||||
export function sanitizeKeyValuePairKey(raw: string): string {
|
||||
return raw.replace(KEY_VALUE_PAIR_UNSAFE_KEY_RE, '').slice(0, KEY_VALUE_PAIR_KEY_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip control characters that enable header injection; allow TAB; cap length.
|
||||
* Removes null bytes, CR/LF and other C0/DEL controls while keeping TAB (\x09),
|
||||
* which is a valid header-value continuation character per RFC 7230.
|
||||
*/
|
||||
export function sanitizeKeyValuePairValue(raw: string): string {
|
||||
return raw.replace(KEY_VALUE_PAIR_UNSAFE_VALUE_RE, '').slice(0, KEY_VALUE_PAIR_VALUE_MAX_LENGTH);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MimeTypeImage } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Convert an SVG base64 data URL to a PNG data URL
|
||||
* @param base64UrlSvg - The SVG base64 data URL to convert
|
||||
* @param backgroundColor - Background color for the PNG (default: 'white')
|
||||
* @returns Promise resolving to PNG data URL
|
||||
*/
|
||||
export function svgBase64UrlToPngDataURL(
|
||||
base64UrlSvg: string,
|
||||
backgroundColor: string = 'white'
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get 2D canvas context.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetWidth = img.naturalWidth || 300;
|
||||
const targetHeight = img.naturalHeight || 300;
|
||||
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
|
||||
if (backgroundColor) {
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
resolve(canvas.toDataURL(MimeTypeImage.PNG));
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load SVG image. Ensure the SVG data is valid.'));
|
||||
};
|
||||
|
||||
img.src = base64UrlSvg;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = `Error converting SVG to PNG: ${message}`;
|
||||
console.error(errorMessage, error);
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an SVG based on its MIME type
|
||||
* @param file - The file to check
|
||||
* @returns True if the file is an SVG
|
||||
*/
|
||||
export function isSvgFile(file: File): boolean {
|
||||
return file.type === MimeTypeImage.SVG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents an SVG
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if the MIME type is image/svg+xml
|
||||
*/
|
||||
export function isSvgMimeType(mimeType: string): boolean {
|
||||
return mimeType === MimeTypeImage.SVG;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Maps file extensions to highlight.js language identifiers
|
||||
*/
|
||||
export function getLanguageFromFilename(filename: string): string {
|
||||
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
|
||||
switch (extension) {
|
||||
// JavaScript / TypeScript
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
case '.cjs':
|
||||
return 'javascript';
|
||||
case '.ts':
|
||||
case '.mts':
|
||||
case '.cts':
|
||||
return 'typescript';
|
||||
case '.jsx':
|
||||
return 'javascript';
|
||||
case '.tsx':
|
||||
return 'typescript';
|
||||
|
||||
// Web
|
||||
case '.html':
|
||||
case '.htm':
|
||||
return 'html';
|
||||
case '.css':
|
||||
return 'css';
|
||||
case '.scss':
|
||||
return 'scss';
|
||||
case '.less':
|
||||
return 'less';
|
||||
case '.vue':
|
||||
return 'html';
|
||||
case '.svelte':
|
||||
return 'html';
|
||||
|
||||
// Data formats
|
||||
case '.json':
|
||||
return 'json';
|
||||
case '.xml':
|
||||
return 'xml';
|
||||
case '.yaml':
|
||||
case '.yml':
|
||||
return 'yaml';
|
||||
case '.toml':
|
||||
return 'ini';
|
||||
case '.csv':
|
||||
return 'plaintext';
|
||||
|
||||
// Programming languages
|
||||
case '.py':
|
||||
return 'python';
|
||||
case '.java':
|
||||
return 'java';
|
||||
case '.kt':
|
||||
case '.kts':
|
||||
return 'kotlin';
|
||||
case '.scala':
|
||||
return 'scala';
|
||||
case '.cpp':
|
||||
case '.cc':
|
||||
case '.cxx':
|
||||
case '.c++':
|
||||
return 'cpp';
|
||||
case '.c':
|
||||
return 'c';
|
||||
case '.h':
|
||||
case '.hpp':
|
||||
return 'cpp';
|
||||
case '.cs':
|
||||
return 'csharp';
|
||||
case '.go':
|
||||
return 'go';
|
||||
case '.rs':
|
||||
return 'rust';
|
||||
case '.rb':
|
||||
return 'ruby';
|
||||
case '.php':
|
||||
return 'php';
|
||||
case '.swift':
|
||||
return 'swift';
|
||||
case '.dart':
|
||||
return 'dart';
|
||||
case '.r':
|
||||
return 'r';
|
||||
case '.lua':
|
||||
return 'lua';
|
||||
case '.pl':
|
||||
case '.pm':
|
||||
return 'perl';
|
||||
|
||||
// Shell
|
||||
case '.sh':
|
||||
case '.bash':
|
||||
case '.zsh':
|
||||
return 'bash';
|
||||
case '.bat':
|
||||
case '.cmd':
|
||||
return 'dos';
|
||||
case '.ps1':
|
||||
return 'powershell';
|
||||
|
||||
// Database
|
||||
case '.sql':
|
||||
return 'sql';
|
||||
|
||||
// Markup / Documentation
|
||||
case '.md':
|
||||
case '.markdown':
|
||||
return 'markdown';
|
||||
case '.tex':
|
||||
case '.latex':
|
||||
return 'latex';
|
||||
case '.adoc':
|
||||
case '.asciidoc':
|
||||
return 'asciidoc';
|
||||
|
||||
// Config
|
||||
case '.ini':
|
||||
case '.cfg':
|
||||
case '.conf':
|
||||
return 'ini';
|
||||
case '.dockerfile':
|
||||
return 'dockerfile';
|
||||
case '.nginx':
|
||||
return 'nginx';
|
||||
|
||||
// Other
|
||||
case '.graphql':
|
||||
case '.gql':
|
||||
return 'graphql';
|
||||
case '.proto':
|
||||
return 'protobuf';
|
||||
case '.diff':
|
||||
case '.patch':
|
||||
return 'diff';
|
||||
case '.log':
|
||||
return 'plaintext';
|
||||
case '.txt':
|
||||
return 'plaintext';
|
||||
|
||||
default:
|
||||
return 'plaintext';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Text file processing utilities
|
||||
* Handles text file detection, reading, and validation
|
||||
*/
|
||||
|
||||
import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants';
|
||||
import type { BinaryDetectionOptions } from '$lib/types';
|
||||
import { FileExtensionText } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Check if a filename indicates a text file based on its extension
|
||||
* @param filename - The filename to check
|
||||
* @returns True if the filename has a recognized text file extension
|
||||
*/
|
||||
export function isTextFileByName(filename: string): boolean {
|
||||
const textExtensions = Object.values(FileExtensionText);
|
||||
|
||||
return textExtensions.some((ext: FileExtensionText) => filename.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's content as text
|
||||
* @param file - The file to read
|
||||
* @returns Promise resolving to the file's text content
|
||||
*/
|
||||
export async function readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result !== null && event.target?.result !== undefined) {
|
||||
resolve(event.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('File reading error'));
|
||||
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic check to determine if content is likely from a text file
|
||||
* Detects binary files by counting suspicious characters and null bytes
|
||||
* @param content - The file content to analyze
|
||||
* @param options - Optional configuration for detection parameters
|
||||
* @returns True if the content appears to be text-based
|
||||
*/
|
||||
export function isLikelyTextFile(
|
||||
content: string,
|
||||
options: Partial<BinaryDetectionOptions> = {}
|
||||
): boolean {
|
||||
if (!content) return true;
|
||||
|
||||
const config = { ...DEFAULT_BINARY_DETECTION_OPTIONS, ...options };
|
||||
const sample = content.substring(0, config.prefixLength);
|
||||
|
||||
let nullCount = 0;
|
||||
let suspiciousControlCount = 0;
|
||||
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const charCode = sample.charCodeAt(i);
|
||||
|
||||
// Count null bytes - these are strong indicators of binary files
|
||||
if (charCode === 0) {
|
||||
nullCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count suspicious control characters
|
||||
// Allow common whitespace characters: tab (9), newline (10), carriage return (13)
|
||||
if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) {
|
||||
// Count most suspicious control characters
|
||||
if (charCode < 8 || (charCode > 13 && charCode < 27)) {
|
||||
suspiciousControlCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Count replacement characters (indicates encoding issues)
|
||||
if (charCode === 0xfffd) {
|
||||
suspiciousControlCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject if too many null bytes
|
||||
if (nullCount > config.maxAbsoluteNullBytes) return false;
|
||||
|
||||
// Reject if too many suspicious characters
|
||||
if (suspiciousControlCount / sample.length > config.suspiciousCharThresholdRatio) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NEWLINE_SEPARATOR } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Returns a shortened preview of the provided content capped at the given length.
|
||||
* Appends an ellipsis when the content exceeds the maximum.
|
||||
*/
|
||||
export function getPreviewText(content: string, max = 150): string {
|
||||
return content.length > max ? content.slice(0, max) + '...' : content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a single-line title from a potentially multi-line prompt.
|
||||
* Uses the first non-empty line if `useFirstLine` is true.
|
||||
*/
|
||||
export function generateConversationTitle(content: string, useFirstLine: boolean = false): string {
|
||||
if (useFirstLine) {
|
||||
const firstLine = content.split(NEWLINE_SEPARATOR).find((line) => line.trim().length > 0);
|
||||
return firstLine ? firstLine.trim() : content.trim();
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
TEMPLATE_EXPRESSION_REGEX,
|
||||
URI_SCHEME_SEPARATOR,
|
||||
URI_TEMPLATE_OPERATORS,
|
||||
URI_TEMPLATE_SEPARATORS,
|
||||
VARIABLE_EXPLODE_MODIFIER_REGEX,
|
||||
VARIABLE_PREFIX_MODIFIER_REGEX,
|
||||
LEADING_SLASHES_REGEX
|
||||
} from '../constants';
|
||||
|
||||
/**
|
||||
* Normalize a resource URI for comparison.
|
||||
*
|
||||
* URI template expansion (especially with path operators like {/var})
|
||||
* can produce URIs that differ from listed resource URIs in slash placement.
|
||||
* For example, the template `svelte://{/slug*}.md` with slug="svelte/$effect"
|
||||
* expands to `svelte:///svelte/$effect.md`, while the listed resource URI is
|
||||
* `svelte://svelte/$effect.md`.
|
||||
*
|
||||
* This function strips extra leading slashes after the scheme to normalize
|
||||
* both forms to the same string for comparison purposes.
|
||||
*
|
||||
* @param uri - The URI to normalize
|
||||
* @returns Normalized URI string
|
||||
*/
|
||||
export function normalizeResourceUri(uri: string): string {
|
||||
const schemeEnd = uri.indexOf(URI_SCHEME_SEPARATOR);
|
||||
if (schemeEnd === -1) return uri;
|
||||
|
||||
const scheme = uri.substring(0, schemeEnd);
|
||||
const rest = uri
|
||||
.substring(schemeEnd + URI_SCHEME_SEPARATOR.length)
|
||||
.replace(LEADING_SLASHES_REGEX, '');
|
||||
|
||||
return `${scheme}${URI_SCHEME_SEPARATOR}${rest}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed variable from a URI template expression.
|
||||
*/
|
||||
export interface UriTemplateVariable {
|
||||
/** Variable name */
|
||||
name: string;
|
||||
/** Operator prefix (+, #, /, etc.) or empty string */
|
||||
operator: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all variable names from a URI template string.
|
||||
*
|
||||
* @param template - URI template string (RFC 6570)
|
||||
* @returns Array of unique variable descriptors
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* extractTemplateVariables("file:///{path}")
|
||||
* // => [{ name: "path", operator: "" }]
|
||||
*
|
||||
* extractTemplateVariables("db://{schema}/{table}")
|
||||
* // => [{ name: "schema", operator: "" }, { name: "table", operator: "" }]
|
||||
* ```
|
||||
*/
|
||||
export function extractTemplateVariables(template: string): UriTemplateVariable[] {
|
||||
const variables: UriTemplateVariable[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
let match;
|
||||
TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
|
||||
|
||||
while ((match = TEMPLATE_EXPRESSION_REGEX.exec(template)) !== null) {
|
||||
const operator = match[1] || '';
|
||||
const varList = match[2];
|
||||
|
||||
// RFC 6570 allows comma-separated variable lists: {x,y,z}
|
||||
for (const varSpec of varList.split(',')) {
|
||||
// Strip explode modifier (*) and prefix modifier (:N)
|
||||
const name = varSpec
|
||||
.replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '')
|
||||
.replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
|
||||
.trim();
|
||||
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name);
|
||||
variables.push({ name, operator });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a URI template with the given variable values.
|
||||
* Implements a simplified RFC 6570 Level 2 expansion.
|
||||
*
|
||||
* @param template - URI template string
|
||||
* @param values - Map of variable name to value
|
||||
* @returns Expanded URI string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expandTemplate("file:///{path}", { path: "src/main.rs" })
|
||||
* // => "file:///src/main.rs"
|
||||
* ```
|
||||
*/
|
||||
export function expandTemplate(template: string, values: Record<string, string>): string {
|
||||
TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
|
||||
|
||||
return template.replace(
|
||||
TEMPLATE_EXPRESSION_REGEX,
|
||||
(_match, operator: string, varList: string) => {
|
||||
const varNames = varList
|
||||
.split(',')
|
||||
.map((v: string) =>
|
||||
v
|
||||
.replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '')
|
||||
.replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
|
||||
.trim()
|
||||
);
|
||||
|
||||
const expandedParts = varNames
|
||||
.map((name: string) => values[name] ?? '')
|
||||
.filter((v: string) => v !== '');
|
||||
|
||||
if (expandedParts.length === 0) return '';
|
||||
|
||||
switch (operator) {
|
||||
case URI_TEMPLATE_OPERATORS.RESERVED:
|
||||
// Reserved expansion: no encoding
|
||||
return expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA);
|
||||
case URI_TEMPLATE_OPERATORS.FRAGMENT:
|
||||
// Fragment expansion
|
||||
return (
|
||||
URI_TEMPLATE_OPERATORS.FRAGMENT + expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA)
|
||||
);
|
||||
case URI_TEMPLATE_OPERATORS.PATH_SEGMENT:
|
||||
// Path segments
|
||||
return URI_TEMPLATE_SEPARATORS.SLASH + expandedParts.join(URI_TEMPLATE_SEPARATORS.SLASH);
|
||||
case URI_TEMPLATE_OPERATORS.LABEL:
|
||||
// Label expansion
|
||||
return (
|
||||
URI_TEMPLATE_SEPARATORS.PERIOD + expandedParts.join(URI_TEMPLATE_SEPARATORS.PERIOD)
|
||||
);
|
||||
case URI_TEMPLATE_OPERATORS.PATH_PARAM:
|
||||
// Path-style parameters
|
||||
return varNames
|
||||
.filter((_: string, i: number) => expandedParts[i])
|
||||
.map(
|
||||
(name: string, i: number) =>
|
||||
`${URI_TEMPLATE_SEPARATORS.SEMICOLON}${name}=${expandedParts[i]}`
|
||||
)
|
||||
.join('');
|
||||
case URI_TEMPLATE_OPERATORS.FORM_QUERY:
|
||||
// Form-style query
|
||||
return (
|
||||
URI_TEMPLATE_SEPARATORS.QUERY_PREFIX +
|
||||
varNames
|
||||
.filter((_: string, i: number) => expandedParts[i])
|
||||
.map(
|
||||
(name: string, i: number) =>
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}`
|
||||
)
|
||||
.join(URI_TEMPLATE_SEPARATORS.COMMA)
|
||||
);
|
||||
case URI_TEMPLATE_OPERATORS.FORM_CONTINUATION:
|
||||
// Form-style query continuation
|
||||
return (
|
||||
URI_TEMPLATE_SEPARATORS.QUERY_CONTINUATION +
|
||||
varNames
|
||||
.filter((_: string, i: number) => expandedParts[i])
|
||||
.map(
|
||||
(name: string, i: number) =>
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}`
|
||||
)
|
||||
.join(URI_TEMPLATE_SEPARATORS.COMMA)
|
||||
);
|
||||
default:
|
||||
// Simple string expansion (default operator)
|
||||
return expandedParts
|
||||
.map((v: string) => encodeURIComponent(v))
|
||||
.join(URI_TEMPLATE_SEPARATORS.COMMA);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether all required variables in a template have been provided.
|
||||
*
|
||||
* @param template - URI template string
|
||||
* @param values - Map of variable name to value
|
||||
* @returns true if all variables have non-empty values
|
||||
*/
|
||||
export function isTemplateComplete(template: string, values: Record<string, string>): boolean {
|
||||
const variables = extractTemplateVariables(template);
|
||||
|
||||
return variables.every((v) => (values[v.name] ?? '').trim() !== '');
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { TWO_PART_PUBLIC_SUFFIXES, WILDCARD_PUBLIC_SUFFIXES } from '$lib/constants';
|
||||
import { UrlProtocol } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Check whether a hostname looks like an IPv4 or IPv6 address.
|
||||
*/
|
||||
function isIpAddress(hostname: string): boolean {
|
||||
if (hostname.includes(':')) return true;
|
||||
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the registrable root domain from a URL.
|
||||
*
|
||||
* @example
|
||||
* 'mcp.example.com' -> 'example.com'
|
||||
* 'www.example.co.uk' -> 'example.co.uk'
|
||||
* 'bar.foo.nom.br' -> 'bar.foo.nom.br'
|
||||
* '192.168.1.1' -> null
|
||||
* 'localhost' -> null
|
||||
*/
|
||||
export function extractRootDomain(url: URL): string | null {
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (!hostname || isIpAddress(hostname)) return null;
|
||||
|
||||
const parts = hostname.split('.');
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
if (parts.length >= 3) {
|
||||
const suffix2 = `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
|
||||
|
||||
if (TWO_PART_PUBLIC_SUFFIXES.has(suffix2)) {
|
||||
return parts.slice(-3).join('.');
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 2; i <= parts.length; i++) {
|
||||
const candidate = parts.slice(-i).join('.');
|
||||
|
||||
if (WILDCARD_PUBLIC_SUFFIXES.has(candidate)) {
|
||||
if (parts.length === i + 1) {
|
||||
return hostname;
|
||||
}
|
||||
|
||||
return parts.slice(-(i + 2)).join('.');
|
||||
}
|
||||
}
|
||||
|
||||
return parts.slice(-2).join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an external URL string for safe use in an `<a href>`.
|
||||
* Only allows http: and https: schemes. Returns `null` for anything else.
|
||||
*/
|
||||
export function sanitizeExternalUrl(raw: string): string | null {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
|
||||
if (url.protocol !== UrlProtocol.HTTP && url.protocol !== UrlProtocol.HTTPS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function uuid(): string {
|
||||
return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).substring(2);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Check if an element is within the current viewport.
|
||||
*/
|
||||
export function isElementInViewport(node: HTMLElement): boolean {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return (
|
||||
rect.top < window.innerHeight &&
|
||||
rect.bottom > 0 &&
|
||||
rect.left < window.innerWidth &&
|
||||
rect.right > 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { FileExtensionImage, MimeTypeImage } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Convert a WebP base64 data URL to a PNG data URL
|
||||
* @param base64UrlWebp - The WebP base64 data URL to convert
|
||||
* @param backgroundColor - Background color for the PNG (default: 'white')
|
||||
* @returns Promise resolving to PNG data URL
|
||||
*/
|
||||
export function webpBase64UrlToPngDataURL(
|
||||
base64UrlWebp: string,
|
||||
backgroundColor: string = 'white'
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get 2D canvas context.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetWidth = img.naturalWidth || 300;
|
||||
const targetHeight = img.naturalHeight || 300;
|
||||
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
|
||||
if (backgroundColor) {
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
resolve(canvas.toDataURL(MimeTypeImage.PNG));
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load WebP image. Ensure the WebP data is valid.'));
|
||||
};
|
||||
|
||||
img.src = base64UrlWebp;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = `Error converting WebP to PNG: ${message}`;
|
||||
console.error(errorMessage, error);
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a WebP based on its MIME type
|
||||
* @param file - The file to check
|
||||
* @returns True if the file is a WebP
|
||||
*/
|
||||
export function isWebpFile(file: File): boolean {
|
||||
return (
|
||||
file.type === MimeTypeImage.WEBP || file.name.toLowerCase().endsWith(FileExtensionImage.WEBP)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents a WebP
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if the MIME type is image/webp
|
||||
*/
|
||||
export function isWebpMimeType(mimeType: string): boolean {
|
||||
return mimeType === MimeTypeImage.WEBP;
|
||||
}
|
||||
Reference in New Issue
Block a user