summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/utils
diff options
context:
space:
mode:
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/utils')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts22
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts45
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts61
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts105
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts226
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts10
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/branching.ts283
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts35
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts259
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts51
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts30
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts192
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts36
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/file-type.ts222
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/formatters.ts53
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/index.ts95
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts5
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts270
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts162
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/model-names.ts56
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts150
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/precision.ts25
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts136
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts71
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts145
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/text-files.ts97
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/text.ts7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts73
29 files changed, 2942 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts b/llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts
new file mode 100644
index 0000000..77ce3e8
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts
@@ -0,0 +1,22 @@
+import { config } from '$lib/stores/settings.svelte';
+
+/**
+ * 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()
+ };
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts b/llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts
new file mode 100644
index 0000000..948b7d7
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts
@@ -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);
+ }
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts b/llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts
new file mode 100644
index 0000000..750aaa3
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts
@@ -0,0 +1,61 @@
+import { FileTypeCategory } from '$lib/enums';
+import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
+
+export interface AttachmentDisplayItemsOptions {
+ uploadedFiles?: ChatUploadedFile[];
+ attachments?: DatabaseMessageExtra[];
+}
+
+/**
+ * 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,
+ 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,
+ preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
+ isImage,
+ attachment,
+ attachmentIndex: index,
+ textContent: 'content' in attachment ? attachment.content : undefined
+ });
+ }
+
+ return items.reverse();
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts b/llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts
new file mode 100644
index 0000000..9e9f096
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts
@@ -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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts b/llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts
new file mode 100644
index 0000000..2a21985
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts
@@ -0,0 +1,226 @@
+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) => {
+ if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') {
+ reject(new Error('No active recording to stop'));
+ return;
+ }
+
+ this.mediaRecorder.onstop = () => {
+ const mimeType = this.mediaRecorder?.mimeType || MimeTypeAudio.WAV;
+ const audioBlob = new Blob(this.audioChunks, { type: mimeType });
+
+ this.cleanup();
+
+ resolve(audioBlob);
+ };
+
+ this.mediaRecorder.onerror = (event) => {
+ console.error('Recording error:', event);
+ this.cleanup();
+ reject(new Error('Recording failed'));
+ };
+
+ this.mediaRecorder.stop();
+ });
+ }
+
+ isRecording(): boolean {
+ return this.recordingState;
+ }
+
+ cancelRecording(): void {
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
+ this.mediaRecorder.stop();
+ }
+ this.cleanup();
+ }
+
+ 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;
+ };
+ }
+
+ private cleanup(): void {
+ if (this.stream) {
+ for (const track of this.stream.getTracks()) {
+ track.stop();
+ }
+
+ this.stream = null;
+ }
+ this.mediaRecorder = null;
+ this.audioChunks = [];
+ 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)();
+
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+
+ const wavBlob = audioBufferToWav(audioBuffer);
+
+ audioContext.close();
+
+ return wavBlob;
+ } 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
+
+ let offset = 44;
+ for (let i = 0; i < length; i++) {
+ for (let channel = 0; channel < numberOfChannels; channel++) {
+ const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i]));
+ view.setInt16(offset, sample * 0x7fff, true);
+ offset += 2;
+ }
+ }
+
+ 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
+ );
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts b/llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts
new file mode 100644
index 0000000..cfee5ec
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts
@@ -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';
+ }
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/branching.ts b/llama.cpp/tools/server/webui/src/lib/utils/branching.ts
new file mode 100644
index 0000000..3be5604
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/branching.ts
@@ -0,0 +1,283 @@
+/**
+ * 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)
+ */
+
+/**
+ * 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 by timestamp to get chronological order (root to leaf)
+ result.sort((a, b) => 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];
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts b/llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts
new file mode 100644
index 0000000..0af8006
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts
@@ -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, type FileProcessingResult } 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';
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts b/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts
new file mode 100644
index 0000000..940e64c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts
@@ -0,0 +1,259 @@
+import { toast } from 'svelte-sonner';
+import { AttachmentType } from '$lib/enums';
+import type {
+ DatabaseMessageExtra,
+ DatabaseMessageExtraTextFile,
+ DatabaseMessageExtraLegacyContext
+} from '$lib/types/database';
+
+/**
+ * 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);
+}
+
+/**
+ * Format for text attachments when copied to clipboard
+ */
+export interface ClipboardTextAttachment {
+ type: typeof AttachmentType.TEXT;
+ name: string;
+ content: string;
+}
+
+/**
+ * Parsed result from clipboard content
+ */
+export interface ParsedClipboardContent {
+ message: string;
+ textAttachments: ClipboardTextAttachment[];
+}
+
+/**
+ * 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 only text attachments (TEXT type and legacy CONTEXT type)
+ const textAttachments =
+ extras?.filter(
+ (extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
+ extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
+ ) ?? [];
+
+ 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: ClipboardTextAttachment[] = textAttachments.map((att) => ({
+ type: AttachmentType.TEXT,
+ name: att.name,
+ content: att.content
+ }));
+
+ 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: []
+ };
+
+ 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: []
+ };
+ }
+
+ const attachments = JSON.parse(remainingPart) as unknown[];
+
+ const validAttachments: ClipboardTextAttachment[] = [];
+
+ for (const att of attachments) {
+ if (isValidTextAttachment(att)) {
+ validAttachments.push({
+ type: AttachmentType.TEXT,
+ name: att.name,
+ content: att.content
+ });
+ }
+ }
+
+ return {
+ message,
+ textAttachments: validAttachments
+ };
+ } catch {
+ return defaultResult;
+ }
+}
+
+/**
+ * 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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts b/llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts
new file mode 100644
index 0000000..b85242d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts
@@ -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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts b/llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts
new file mode 100644
index 0000000..aee244a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts
@@ -0,0 +1,30 @@
+/**
+ * Utility functions for conversation data manipulation
+ */
+
+/**
+ * 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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts b/llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts
new file mode 100644
index 0000000..6eb50f6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts
@@ -0,0 +1,192 @@
+import { convertPDFToImage, convertPDFToText } from './pdf-processing';
+import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
+import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
+import { FileTypeCategory, AttachmentType } from '$lib/enums';
+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';
+
+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 interface FileProcessingResult {
+ extras: DatabaseMessageExtra[];
+ emptyFiles: string[];
+}
+
+export async function parseFilesToMessageExtras(
+ files: ChatUploadedFile[],
+ activeModelId?: string
+): Promise<FileProcessingResult> {
+ const extras: DatabaseMessageExtra[] = [];
+ const emptyFiles: string[] = [];
+
+ for (const file of files) {
+ 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,
+ 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,
+ 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('pdfAsImage', 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,
+ 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,
+ 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,
+ 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,
+ 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 };
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts b/llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts
new file mode 100644
index 0000000..26a6053
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts
@@ -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();
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/file-type.ts b/llama.cpp/tools/server/webui/src/lib/utils/file-type.ts
new file mode 100644
index 0000000..9a9996d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/file-type.ts
@@ -0,0 +1,222 @@
+import {
+ AUDIO_FILE_TYPES,
+ IMAGE_FILE_TYPES,
+ PDF_FILE_TYPES,
+ TEXT_FILE_TYPES
+} from '$lib/constants/supported-file-types';
+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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/formatters.ts b/llama.cpp/tools/server/webui/src/lib/utils/formatters.ts
new file mode 100644
index 0000000..ae9f59a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/formatters.ts
@@ -0,0 +1,53 @@
+/**
+ * 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();
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/index.ts b/llama.cpp/tools/server/webui/src/lib/utils/index.ts
new file mode 100644
index 0000000..588167b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/index.ts
@@ -0,0 +1,95 @@
+/**
+ * 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 } from './api-headers';
+export { validateApiKey } from './api-key-validation';
+
+// Attachment utilities
+export {
+ getAttachmentDisplayItems,
+ type AttachmentDisplayItemsOptions
+} from './attachment-display';
+export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type';
+
+// Textarea utilities
+export { default as autoResizeTextarea } from './autoresize-textarea';
+
+// Branching utilities
+export {
+ filterByLeafNodeId,
+ findLeafNode,
+ findDescendantMessages,
+ getMessageSiblings,
+ getMessageDisplayList,
+ hasMessageSiblings,
+ getNextSibling,
+ getPreviousSibling
+} from './branching';
+
+// Config helpers
+export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
+
+// Conversation utilities
+export { createMessageCountMap, getMessageCount } from './conversation-utils';
+
+// Clipboard utilities
+export {
+ copyToClipboard,
+ copyCodeToClipboard,
+ formatMessageForClipboard,
+ parseClipboardContent,
+ hasClipboardAttachments,
+ type ClipboardTextAttachment,
+ type ParsedClipboardContent
+} from './clipboard';
+
+// File preview utilities
+export { getFileTypeLabel } from './file-preview';
+export { getPreviewText } from './text';
+
+// File type utilities
+export {
+ getFileTypeCategory,
+ getFileTypeCategoryByExtension,
+ getFileTypeByExtension,
+ isFileTypeSupported
+} from './file-type';
+
+// Formatting utilities
+export { formatFileSize, formatParameters, formatNumber } 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,
+ type ModalityCapabilities
+} 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';
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts b/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts
new file mode 100644
index 0000000..9182ea4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts
@@ -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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts b/llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts
new file mode 100644
index 0000000..cafa2d4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts
@@ -0,0 +1,270 @@
+import {
+ CODE_BLOCK_REGEXP,
+ LATEX_MATH_AND_CODE_PATTERN,
+ LATEX_LINEBREAK_REGEXP,
+ MHCHEM_PATTERN_MAP
+} from '$lib/constants/latex-protection';
+
+/**
+ * Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs
+ * that appear to be part of monetary values or identifiers.
+ *
+ * This function processes the input line by line and skips `$` sequences that are likely
+ * part of money amounts (e.g., `$5`, `$100.99`) or code-like tokens (e.g., `var$`, `$var`).
+ * Valid LaTeX inline math is replaced with a placeholder like `<<LATEX_0>>`, and the
+ * actual LaTeX content is stored in the provided `latexExpressions` array.
+ *
+ * @param content - The input text potentially containing LaTeX expressions.
+ * @param latexExpressions - An array used to collect extracted LaTeX expressions.
+ * @returns The processed string with LaTeX replaced by placeholders.
+ */
+export function maskInlineLaTeX(content: string, latexExpressions: string[]): string {
+ if (!content.includes('$')) {
+ return content;
+ }
+ return content
+ .split('\n')
+ .map((line) => {
+ if (line.indexOf('$') == -1) {
+ return line;
+ }
+
+ let processedLine = '';
+ let currentPosition = 0;
+
+ while (currentPosition < line.length) {
+ const openDollarIndex = line.indexOf('$', currentPosition);
+
+ if (openDollarIndex == -1) {
+ processedLine += line.slice(currentPosition);
+ break;
+ }
+
+ // Is there a next $-sign?
+ const closeDollarIndex = line.indexOf('$', openDollarIndex + 1);
+
+ if (closeDollarIndex == -1) {
+ processedLine += line.slice(currentPosition);
+ break;
+ }
+
+ const charBeforeOpen = openDollarIndex > 0 ? line[openDollarIndex - 1] : '';
+ const charAfterOpen = line[openDollarIndex + 1];
+ const charBeforeClose =
+ openDollarIndex + 1 < closeDollarIndex ? line[closeDollarIndex - 1] : '';
+ const charAfterClose = closeDollarIndex + 1 < line.length ? line[closeDollarIndex + 1] : '';
+
+ let shouldSkipAsNonLatex = false;
+
+ if (closeDollarIndex == currentPosition + 1) {
+ // No content
+ shouldSkipAsNonLatex = true;
+ }
+
+ if (/[A-Za-z0-9_$-]/.test(charBeforeOpen)) {
+ // Character, digit, $, _ or - before first '$', no TeX.
+ shouldSkipAsNonLatex = true;
+ }
+
+ if (
+ /[0-9]/.test(charAfterOpen) &&
+ (/[A-Za-z0-9_$-]/.test(charAfterClose) || ' ' == charBeforeClose)
+ ) {
+ // First $ seems to belong to an amount.
+ shouldSkipAsNonLatex = true;
+ }
+
+ if (shouldSkipAsNonLatex) {
+ processedLine += line.slice(currentPosition, openDollarIndex + 1);
+ currentPosition = openDollarIndex + 1;
+
+ continue;
+ }
+
+ // Treat as LaTeX
+ processedLine += line.slice(currentPosition, openDollarIndex);
+ const latexContent = line.slice(openDollarIndex, closeDollarIndex + 1);
+ latexExpressions.push(latexContent);
+ processedLine += `<<LATEX_${latexExpressions.length - 1}>>`;
+ currentPosition = closeDollarIndex + 1;
+ }
+
+ return processedLine;
+ })
+ .join('\n');
+}
+
+function escapeBrackets(text: string): string {
+ return text.replace(
+ LATEX_MATH_AND_CODE_PATTERN,
+ (
+ match: string,
+ codeBlock: string | undefined,
+ squareBracket: string | undefined,
+ roundBracket: string | undefined
+ ): string => {
+ if (codeBlock != null) {
+ return codeBlock;
+ } else if (squareBracket != null) {
+ return `$$${squareBracket}$$`;
+ } else if (roundBracket != null) {
+ return `$${roundBracket}$`;
+ }
+
+ return match;
+ }
+ );
+}
+
+// Escape $\\ce{...} → $\\ce{...} but with proper handling
+function escapeMhchem(text: string): string {
+ return MHCHEM_PATTERN_MAP.reduce((result, [pattern, replacement]) => {
+ return result.replace(pattern, replacement);
+ }, text);
+}
+
+const doEscapeMhchem = false;
+
+/**
+ * Preprocesses markdown content to safely handle LaTeX math expressions while protecting
+ * against false positives (e.g., dollar amounts like $5.99) and ensuring proper rendering.
+ *
+ * This function:
+ * - Protects code blocks (```) and inline code (`...`)
+ * - Safeguards block and inline LaTeX: \(...\), \[...\], $$...$$, and selective $...$
+ * - Escapes standalone dollar signs before numbers (e.g., $5 → \$5) to prevent misinterpretation
+ * - Restores protected LaTeX and code blocks after processing
+ * - Converts \(...\) → $...$ and \[...\] → $$...$$ for compatibility with math renderers
+ * - Applies additional escaping for brackets and mhchem syntax if needed
+ *
+ * @param content - The raw text (e.g., markdown) that may contain LaTeX or code blocks.
+ * @returns The preprocessed string with properly escaped and normalized LaTeX.
+ *
+ * @example
+ * preprocessLaTeX("Price: $10. The equation is \\(x^2\\).")
+ * // → "Price: $10. The equation is $x^2$."
+ */
+export function preprocessLaTeX(content: string): string {
+ // See also:
+ // https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
+
+ // Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly
+ // Store the structure so we can restore it later
+ const blockquoteMarkers: Map<number, string> = new Map();
+ const lines = content.split('\n');
+ const processedLines = lines.map((line, index) => {
+ const match = line.match(/^(>\s*)/);
+ if (match) {
+ blockquoteMarkers.set(index, match[1]);
+ return line.slice(match[1].length);
+ }
+ return line;
+ });
+ content = processedLines.join('\n');
+
+ // Step 1: Protect code blocks
+ const codeBlocks: string[] = [];
+
+ content = content.replace(CODE_BLOCK_REGEXP, (match) => {
+ codeBlocks.push(match);
+
+ return `<<CODE_BLOCK_${codeBlocks.length - 1}>>`;
+ });
+
+ // Step 2: Protect existing LaTeX expressions
+ const latexExpressions: string[] = [];
+
+ // Match \S...\[...\] and protect them and insert a line-break.
+ content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => {
+ // Check if there are characters following the formula (display-formula in a table-cell?)
+ if (group1.endsWith('\\')) {
+ return match; // Backslash before \[, do nothing.
+ }
+ const hasSuffix = /\S/.test(group3);
+ let optBreak;
+
+ if (hasSuffix) {
+ latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
+ optBreak = '';
+ } else {
+ latexExpressions.push(`\\[${group2}\\]`);
+ optBreak = '\n';
+ }
+
+ return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
+ });
+
+ // Match \(...\), \[...\], $$...$$ and protect them
+ content = content.replace(
+ /(\$\$[\s\S]*?\$\$|(?<!\\)\\\[[\s\S]*?\\\]|(?<!\\)\\\(.*?\\\))/g,
+ (match) => {
+ latexExpressions.push(match);
+
+ return `<<LATEX_${latexExpressions.length - 1}>>`;
+ }
+ );
+
+ // Protect inline $...$ but NOT if it looks like money (e.g., $10, $3.99)
+ content = maskInlineLaTeX(content, latexExpressions);
+
+ // Step 3: Escape standalone $ before digits (currency like $5 → \$5)
+ // (Now that inline math is protected, this will only escape dollars not already protected)
+ content = content.replace(/\$(?=\d)/g, '\\$');
+
+ // Step 4: Restore protected LaTeX expressions (they are valid)
+ content = content.replace(/<<LATEX_(\d+)>>/g, (_, index) => {
+ let expr = latexExpressions[parseInt(index)];
+ const match = expr.match(LATEX_LINEBREAK_REGEXP);
+ if (match) {
+ // Katex: The $$-delimiters should be in their own line
+ // if there are \\-line-breaks.
+ const formula = match[1];
+ const prefix = formula.startsWith('\n') ? '' : '\n';
+ const suffix = formula.endsWith('\n') ? '' : '\n';
+ expr = '$$' + prefix + formula + suffix + '$$';
+ }
+ return expr;
+ });
+
+ // Step 5: Apply additional escaping functions (brackets and mhchem)
+ // This must happen BEFORE restoring code blocks to avoid affecting code content
+ content = escapeBrackets(content);
+
+ if (doEscapeMhchem && (content.includes('\\ce{') || content.includes('\\pu{'))) {
+ content = escapeMhchem(content);
+ }
+
+ // Step 6: Convert remaining \(...\) → $...$, \[...\] → $$...$$
+ // This must happen BEFORE restoring code blocks to avoid affecting code content
+ content = content
+ // Using the look‑behind pattern `(?<!\\)` we skip matches
+ // that are preceded by a backslash, e.g.
+ // `Definitions\\(also called macros)` (title of chapter 20 in The TeXbook).
+ .replace(/(?<!\\)\\\((.+?)\\\)/g, '$$$1$') // inline
+ .replace(
+ // Using the look‑behind pattern `(?<!\\)` we skip matches
+ // that are preceded by a backslash, e.g. `\\[4pt]`.
+ /(?<!\\)\\\[([\s\S]*?)\\\]/g, // display, see also PR #16599
+ (_, content: string) => {
+ return `$$${content}$$`;
+ }
+ );
+
+ // Step 7: Restore code blocks
+ // This happens AFTER all LaTeX conversions to preserve code content
+ content = content.replace(/<<CODE_BLOCK_(\d+)>>/g, (_, index) => {
+ return codeBlocks[parseInt(index)];
+ });
+
+ // Step 8: Restore blockquote markers
+ if (blockquoteMarkers.size > 0) {
+ const finalLines = content.split('\n');
+ const restoredLines = finalLines.map((line, index) => {
+ const marker = blockquoteMarkers.get(index);
+ return marker ? marker + line : line;
+ });
+ content = restoredLines.join('\n');
+ }
+
+ return content;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts b/llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts
new file mode 100644
index 0000000..136c084
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts
@@ -0,0 +1,162 @@
+/**
+ * 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';
+
+/** Modality capabilities for file validation */
+export interface ModalityCapabilities {
+ hasVision: boolean;
+ hasAudio: boolean;
+}
+
+/**
+ * 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
+ */
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/model-names.ts b/llama.cpp/tools/server/webui/src/lib/utils/model-names.ts
new file mode 100644
index 0000000..c0a1e1c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/model-names.ts
@@ -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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts b/llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts
new file mode 100644
index 0000000..84c456d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts
@@ -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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts b/llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts
new file mode 100644
index 0000000..bffbe89
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts
@@ -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);
+ }
+ }
+ };
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/precision.ts b/llama.cpp/tools/server/webui/src/lib/utils/precision.ts
new file mode 100644
index 0000000..6da200c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/precision.ts
@@ -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/precision';
+
+/**
+ * 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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts b/llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts
new file mode 100644
index 0000000..0342dce
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts
@@ -0,0 +1,136 @@
+import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
+import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
+import { FileTypeCategory } from '$lib/enums';
+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('pdfAsImage', 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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts b/llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts
new file mode 100644
index 0000000..d5a7f7d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts
@@ -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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts b/llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts
new file mode 100644
index 0000000..5384291
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts
@@ -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';
+ }
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/text-files.ts b/llama.cpp/tools/server/webui/src/lib/utils/text-files.ts
new file mode 100644
index 0000000..e8006de
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/text-files.ts
@@ -0,0 +1,97 @@
+/**
+ * Text file processing utilities
+ * Handles text file detection, reading, and validation
+ */
+
+import {
+ DEFAULT_BINARY_DETECTION_OPTIONS,
+ type BinaryDetectionOptions
+} from '$lib/constants/binary-detection';
+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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/text.ts b/llama.cpp/tools/server/webui/src/lib/utils/text.ts
new file mode 100644
index 0000000..5c5dd0f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/text.ts
@@ -0,0 +1,7 @@
+/**
+ * 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;
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts b/llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts
new file mode 100644
index 0000000..ea51838
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts
@@ -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;
+}