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:
Aleksander Grygier
2026-05-16 02:02:40 +02:00
committed by GitHub
co-authored by Xuan Son Nguyen
parent 49d1701bd2
commit 59778f0196
565 changed files with 1610 additions and 694 deletions
+151
View File
@@ -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);
});
});
}
+227
View File
@@ -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;
}
+158
View File
@@ -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}`;
}
+67
View File
@@ -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();
}
+105
View File
@@ -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;
}
+257
View File
@@ -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';
}
}
+301
View File
@@ -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];
}
+35
View File
@@ -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';
+292
View File
@@ -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);
}
}
}
+311
View File
@@ -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;
}
+85
View File
@@ -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, '&amp;')
.replace(LT_REGEX, '&lt;')
.replace(GT_REGEX, '&gt;');
}
}
/**
* 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
};
}
+51
View File
@@ -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 };
}
+35
View File
@@ -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;
}
+9
View File
@@ -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;
}
+10
View File
@@ -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}`;
}
+22
View File
@@ -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);
};
}
+36
View File
@@ -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();
}
+222
View File
@@ -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;
}
+153
View File
@@ -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}`;
}
+44
View File
@@ -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>`;
}
+192
View File
@@ -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;
}
+270
View File
@@ -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 lookbehind 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 lookbehind 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;
}
+361
View File
@@ -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();
}
}
+304
View File
@@ -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
*/
+56
View File
@@ -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;
}
+150
View File
@@ -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;
}
+20
View File
@@ -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);
}
}
};
}
+25
View File
@@ -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;
}
+14
View File
@@ -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]';
}
+111
View File
@@ -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;
}
}
+23
View File
@@ -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);
}
+71
View File
@@ -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';
}
}
+95
View File
@@ -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;
}
+22
View File
@@ -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();
}
+198
View File
@@ -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() !== '');
}
+72
View File
@@ -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;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function uuid(): string {
return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).substring(2);
}
+12
View File
@@ -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
);
}
+73
View File
@@ -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;
}