summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/utils
diff options
context:
space:
mode:
authorMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
committerMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
commitb333b06772c89d96aacb5490d6a219fba7c09cc6 (patch)
tree211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/utils
downloadllmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz
Engage!
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 @@
1import { config } from '$lib/stores/settings.svelte';
2
3/**
4 * Get authorization headers for API requests
5 * Includes Bearer token if API key is configured
6 */
7export function getAuthHeaders(): Record<string, string> {
8 const currentConfig = config();
9 const apiKey = currentConfig.apiKey?.toString().trim();
10
11 return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
12}
13
14/**
15 * Get standard JSON headers with optional authorization
16 */
17export function getJsonHeaders(): Record<string, string> {
18 return {
19 'Content-Type': 'application/json',
20 ...getAuthHeaders()
21 };
22}
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 @@
1import { base } from '$app/paths';
2import { error } from '@sveltejs/kit';
3import { browser } from '$app/environment';
4import { config } from '$lib/stores/settings.svelte';
5
6/**
7 * Validates API key by making a request to the server props endpoint
8 * Throws SvelteKit errors for authentication failures or server issues
9 */
10export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<void> {
11 if (!browser) {
12 return;
13 }
14
15 try {
16 const apiKey = config().apiKey;
17
18 const headers: Record<string, string> = {
19 'Content-Type': 'application/json'
20 };
21
22 if (apiKey) {
23 headers.Authorization = `Bearer ${apiKey}`;
24 }
25
26 const response = await fetch(`${base}/props`, { headers });
27
28 if (!response.ok) {
29 if (response.status === 401 || response.status === 403) {
30 throw error(401, 'Access denied');
31 }
32
33 console.warn(`Server responded with status ${response.status} during API key validation`);
34 return;
35 }
36 } catch (err) {
37 // If it's already a SvelteKit error, re-throw it
38 if (err && typeof err === 'object' && 'status' in err) {
39 throw err;
40 }
41
42 // Network or other errors
43 console.warn('Cannot connect to server for API key validation:', err);
44 }
45}
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 @@
1import { FileTypeCategory } from '$lib/enums';
2import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
3
4export interface AttachmentDisplayItemsOptions {
5 uploadedFiles?: ChatUploadedFile[];
6 attachments?: DatabaseMessageExtra[];
7}
8
9/**
10 * Gets the file type category from an uploaded file, checking both MIME type and extension
11 */
12function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | null {
13 const categoryByMime = getFileTypeCategory(file.type);
14
15 if (categoryByMime) {
16 return categoryByMime;
17 }
18
19 return getFileTypeCategoryByExtension(file.name);
20}
21
22/**
23 * Creates a unified list of display items from uploaded files and stored attachments.
24 * Items are returned in reverse order (newest first).
25 */
26export function getAttachmentDisplayItems(
27 options: AttachmentDisplayItemsOptions
28): ChatAttachmentDisplayItem[] {
29 const { uploadedFiles = [], attachments = [] } = options;
30 const items: ChatAttachmentDisplayItem[] = [];
31
32 // Add uploaded files (ChatForm)
33 for (const file of uploadedFiles) {
34 items.push({
35 id: file.id,
36 name: file.name,
37 size: file.size,
38 preview: file.preview,
39 isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
40 uploadedFile: file,
41 textContent: file.textContent
42 });
43 }
44
45 // Add stored attachments (ChatMessage)
46 for (const [index, attachment] of attachments.entries()) {
47 const isImage = isImageFile(attachment);
48
49 items.push({
50 id: `attachment-${index}`,
51 name: attachment.name,
52 preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
53 isImage,
54 attachment,
55 attachmentIndex: index,
56 textContent: 'content' in attachment ? attachment.content : undefined
57 });
58 }
59
60 return items.reverse();
61}
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 @@
1import { AttachmentType, FileTypeCategory } from '$lib/enums';
2import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils';
3
4/**
5 * Gets the file type category from an uploaded file, checking both MIME type and extension
6 * @param uploadedFile - The uploaded file to check
7 * @returns The file type category or null if not recognized
8 */
9function getUploadedFileCategory(uploadedFile: ChatUploadedFile): FileTypeCategory | null {
10 // First try MIME type
11 const categoryByMime = getFileTypeCategory(uploadedFile.type);
12
13 if (categoryByMime) {
14 return categoryByMime;
15 }
16
17 // Fallback to extension (browsers don't always provide correct MIME types)
18 return getFileTypeCategoryByExtension(uploadedFile.name);
19}
20
21/**
22 * Determines if an attachment or uploaded file is a text file
23 * @param uploadedFile - Optional uploaded file
24 * @param attachment - Optional database attachment
25 * @returns true if the file is a text file
26 */
27export function isTextFile(
28 attachment?: DatabaseMessageExtra,
29 uploadedFile?: ChatUploadedFile
30): boolean {
31 if (uploadedFile) {
32 return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT;
33 }
34
35 if (attachment) {
36 return (
37 attachment.type === AttachmentType.TEXT || attachment.type === AttachmentType.LEGACY_CONTEXT
38 );
39 }
40
41 return false;
42}
43
44/**
45 * Determines if an attachment or uploaded file is an image
46 * @param uploadedFile - Optional uploaded file
47 * @param attachment - Optional database attachment
48 * @returns true if the file is an image
49 */
50export function isImageFile(
51 attachment?: DatabaseMessageExtra,
52 uploadedFile?: ChatUploadedFile
53): boolean {
54 if (uploadedFile) {
55 return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE;
56 }
57
58 if (attachment) {
59 return attachment.type === AttachmentType.IMAGE;
60 }
61
62 return false;
63}
64
65/**
66 * Determines if an attachment or uploaded file is a PDF
67 * @param uploadedFile - Optional uploaded file
68 * @param attachment - Optional database attachment
69 * @returns true if the file is a PDF
70 */
71export function isPdfFile(
72 attachment?: DatabaseMessageExtra,
73 uploadedFile?: ChatUploadedFile
74): boolean {
75 if (uploadedFile) {
76 return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF;
77 }
78
79 if (attachment) {
80 return attachment.type === AttachmentType.PDF;
81 }
82
83 return false;
84}
85
86/**
87 * Determines if an attachment or uploaded file is an audio file
88 * @param uploadedFile - Optional uploaded file
89 * @param attachment - Optional database attachment
90 * @returns true if the file is an audio file
91 */
92export function isAudioFile(
93 attachment?: DatabaseMessageExtra,
94 uploadedFile?: ChatUploadedFile
95): boolean {
96 if (uploadedFile) {
97 return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO;
98 }
99
100 if (attachment) {
101 return attachment.type === AttachmentType.AUDIO;
102 }
103
104 return false;
105}
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 @@
1import { MimeTypeAudio } from '$lib/enums';
2
3/**
4 * AudioRecorder - Browser-based audio recording with MediaRecorder API
5 *
6 * This class provides a complete audio recording solution using the browser's MediaRecorder API.
7 * It handles microphone access, recording state management, and audio format optimization.
8 *
9 * **Features:**
10 * - Automatic microphone permission handling
11 * - Audio enhancement (echo cancellation, noise suppression, auto gain)
12 * - Multiple format support with fallback (WAV, WebM, MP4, AAC)
13 * - Real-time recording state tracking
14 * - Proper cleanup and resource management
15 */
16export class AudioRecorder {
17 private mediaRecorder: MediaRecorder | null = null;
18 private audioChunks: Blob[] = [];
19 private stream: MediaStream | null = null;
20 private recordingState: boolean = false;
21
22 async startRecording(): Promise<void> {
23 try {
24 this.stream = await navigator.mediaDevices.getUserMedia({
25 audio: {
26 echoCancellation: true,
27 noiseSuppression: true,
28 autoGainControl: true
29 }
30 });
31
32 this.initializeRecorder(this.stream);
33
34 this.audioChunks = [];
35 // Start recording with a small timeslice to ensure we get data
36 this.mediaRecorder!.start(100);
37 this.recordingState = true;
38 } catch (error) {
39 console.error('Failed to start recording:', error);
40 throw new Error('Failed to access microphone. Please check permissions.');
41 }
42 }
43
44 async stopRecording(): Promise<Blob> {
45 return new Promise((resolve, reject) => {
46 if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') {
47 reject(new Error('No active recording to stop'));
48 return;
49 }
50
51 this.mediaRecorder.onstop = () => {
52 const mimeType = this.mediaRecorder?.mimeType || MimeTypeAudio.WAV;
53 const audioBlob = new Blob(this.audioChunks, { type: mimeType });
54
55 this.cleanup();
56
57 resolve(audioBlob);
58 };
59
60 this.mediaRecorder.onerror = (event) => {
61 console.error('Recording error:', event);
62 this.cleanup();
63 reject(new Error('Recording failed'));
64 };
65
66 this.mediaRecorder.stop();
67 });
68 }
69
70 isRecording(): boolean {
71 return this.recordingState;
72 }
73
74 cancelRecording(): void {
75 if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
76 this.mediaRecorder.stop();
77 }
78 this.cleanup();
79 }
80
81 private initializeRecorder(stream: MediaStream): void {
82 const options: MediaRecorderOptions = {};
83
84 if (MediaRecorder.isTypeSupported(MimeTypeAudio.WAV)) {
85 options.mimeType = MimeTypeAudio.WAV;
86 } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM_OPUS)) {
87 options.mimeType = MimeTypeAudio.WEBM_OPUS;
88 } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM)) {
89 options.mimeType = MimeTypeAudio.WEBM;
90 } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.MP4)) {
91 options.mimeType = MimeTypeAudio.MP4;
92 } else {
93 console.warn('No preferred audio format supported, using default');
94 }
95
96 this.mediaRecorder = new MediaRecorder(stream, options);
97
98 this.mediaRecorder.ondataavailable = (event) => {
99 if (event.data.size > 0) {
100 this.audioChunks.push(event.data);
101 }
102 };
103
104 this.mediaRecorder.onstop = () => {
105 this.recordingState = false;
106 };
107
108 this.mediaRecorder.onerror = (event) => {
109 console.error('MediaRecorder error:', event);
110 this.recordingState = false;
111 };
112 }
113
114 private cleanup(): void {
115 if (this.stream) {
116 for (const track of this.stream.getTracks()) {
117 track.stop();
118 }
119
120 this.stream = null;
121 }
122 this.mediaRecorder = null;
123 this.audioChunks = [];
124 this.recordingState = false;
125 }
126}
127
128export async function convertToWav(audioBlob: Blob): Promise<Blob> {
129 try {
130 if (audioBlob.type.includes('wav')) {
131 return audioBlob;
132 }
133
134 const arrayBuffer = await audioBlob.arrayBuffer();
135
136 // eslint-disable-next-line @typescript-eslint/no-explicit-any
137 const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
138
139 const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
140
141 const wavBlob = audioBufferToWav(audioBuffer);
142
143 audioContext.close();
144
145 return wavBlob;
146 } catch (error) {
147 console.error('Failed to convert audio to WAV:', error);
148 return audioBlob;
149 }
150}
151
152function audioBufferToWav(buffer: AudioBuffer): Blob {
153 const length = buffer.length;
154 const numberOfChannels = buffer.numberOfChannels;
155 const sampleRate = buffer.sampleRate;
156 const bytesPerSample = 2; // 16-bit
157 const blockAlign = numberOfChannels * bytesPerSample;
158 const byteRate = sampleRate * blockAlign;
159 const dataSize = length * blockAlign;
160 const bufferSize = 44 + dataSize;
161
162 const arrayBuffer = new ArrayBuffer(bufferSize);
163 const view = new DataView(arrayBuffer);
164
165 const writeString = (offset: number, string: string) => {
166 for (let i = 0; i < string.length; i++) {
167 view.setUint8(offset + i, string.charCodeAt(i));
168 }
169 };
170
171 writeString(0, 'RIFF'); // ChunkID
172 view.setUint32(4, bufferSize - 8, true); // ChunkSize
173 writeString(8, 'WAVE'); // Format
174 writeString(12, 'fmt '); // Subchunk1ID
175 view.setUint32(16, 16, true); // Subchunk1Size
176 view.setUint16(20, 1, true); // AudioFormat (PCM)
177 view.setUint16(22, numberOfChannels, true); // NumChannels
178 view.setUint32(24, sampleRate, true); // SampleRate
179 view.setUint32(28, byteRate, true); // ByteRate
180 view.setUint16(32, blockAlign, true); // BlockAlign
181 view.setUint16(34, 16, true); // BitsPerSample
182 writeString(36, 'data'); // Subchunk2ID
183 view.setUint32(40, dataSize, true); // Subchunk2Size
184
185 let offset = 44;
186 for (let i = 0; i < length; i++) {
187 for (let channel = 0; channel < numberOfChannels; channel++) {
188 const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i]));
189 view.setInt16(offset, sample * 0x7fff, true);
190 offset += 2;
191 }
192 }
193
194 return new Blob([arrayBuffer], { type: MimeTypeAudio.WAV });
195}
196
197/**
198 * Create a File object from audio blob with timestamp-based naming
199 * @param audioBlob - The audio blob to wrap
200 * @param filename - Optional custom filename
201 * @returns File object with appropriate name and metadata
202 */
203export function createAudioFile(audioBlob: Blob, filename?: string): File {
204 const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
205 const extension = audioBlob.type.includes('wav') ? 'wav' : 'mp3';
206 const defaultFilename = `recording-${timestamp}.${extension}`;
207
208 return new File([audioBlob], filename || defaultFilename, {
209 type: audioBlob.type,
210 lastModified: Date.now()
211 });
212}
213
214/**
215 * Check if audio recording is supported in the current browser
216 * @returns True if MediaRecorder and getUserMedia are available
217 */
218export function isAudioRecordingSupported(): boolean {
219 return !!(
220 typeof navigator !== 'undefined' &&
221 navigator.mediaDevices &&
222 typeof navigator.mediaDevices.getUserMedia === 'function' &&
223 typeof window !== 'undefined' &&
224 window.MediaRecorder
225 );
226}
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 @@
1/**
2 * Automatically resizes a textarea element to fit its content
3 * @param textareaElement - The textarea element to resize
4 */
5export default function autoResizeTextarea(textareaElement: HTMLTextAreaElement | null): void {
6 if (textareaElement) {
7 textareaElement.style.height = '1rem';
8 textareaElement.style.height = textareaElement.scrollHeight + 'px';
9 }
10}
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 @@
1/**
2 * Message branching utilities for conversation tree navigation.
3 *
4 * Conversation branching allows users to edit messages and create alternate paths
5 * while preserving the original conversation flow. Each message has parent/children
6 * relationships forming a tree structure.
7 *
8 * Example tree:
9 * root
10 * ├── message 1 (user)
11 * │ └── message 2 (assistant)
12 * │ ├── message 3 (user)
13 * │ └── message 6 (user) ← new branch
14 * └── message 4 (user)
15 * └── message 5 (assistant)
16 */
17
18/**
19 * Filters messages to get the conversation path from root to a specific leaf node.
20 * If the leafNodeId doesn't exist, returns the path with the latest timestamp.
21 *
22 * @param messages - All messages in the conversation
23 * @param leafNodeId - The target leaf node ID to trace back from
24 * @param includeRoot - Whether to include root messages in the result
25 * @returns Array of messages from root to leaf, sorted by timestamp
26 */
27export function filterByLeafNodeId(
28 messages: readonly DatabaseMessage[],
29 leafNodeId: string,
30 includeRoot: boolean = false
31): readonly DatabaseMessage[] {
32 const result: DatabaseMessage[] = [];
33 const nodeMap = new Map<string, DatabaseMessage>();
34
35 // Build node map for quick lookups
36 for (const msg of messages) {
37 nodeMap.set(msg.id, msg);
38 }
39
40 // Find the starting node (leaf node or latest if not found)
41 let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId);
42 if (!startNode) {
43 // If leaf node not found, use the message with latest timestamp
44 let latestTime = -1;
45 for (const msg of messages) {
46 if (msg.timestamp > latestTime) {
47 startNode = msg;
48 latestTime = msg.timestamp;
49 }
50 }
51 }
52
53 // Traverse from leaf to root, collecting messages
54 let currentNode: DatabaseMessage | undefined = startNode;
55 while (currentNode) {
56 // Include message if it's not root, or if we want to include root
57 if (currentNode.type !== 'root' || includeRoot) {
58 result.push(currentNode);
59 }
60
61 // Stop traversal if parent is null (reached root)
62 if (currentNode.parent === null) {
63 break;
64 }
65 currentNode = nodeMap.get(currentNode.parent);
66 }
67
68 // Sort by timestamp to get chronological order (root to leaf)
69 result.sort((a, b) => a.timestamp - b.timestamp);
70 return result;
71}
72
73/**
74 * Finds the leaf node (message with no children) for a given message branch.
75 * Traverses down the tree following the last child until reaching a leaf.
76 *
77 * @param messages - All messages in the conversation
78 * @param messageId - Starting message ID to find leaf for
79 * @returns The leaf node ID, or the original messageId if no children
80 */
81export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string {
82 const nodeMap = new Map<string, DatabaseMessage>();
83
84 // Build node map for quick lookups
85 for (const msg of messages) {
86 nodeMap.set(msg.id, msg);
87 }
88
89 let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
90 while (currentNode && currentNode.children.length > 0) {
91 // Follow the last child (most recent branch)
92 const lastChildId = currentNode.children[currentNode.children.length - 1];
93 currentNode = nodeMap.get(lastChildId);
94 }
95
96 return currentNode?.id ?? messageId;
97}
98
99/**
100 * Finds all descendant messages (children, grandchildren, etc.) of a given message.
101 * This is used for cascading deletion to remove all messages in a branch.
102 *
103 * @param messages - All messages in the conversation
104 * @param messageId - The root message ID to find descendants for
105 * @returns Array of all descendant message IDs
106 */
107export function findDescendantMessages(
108 messages: readonly DatabaseMessage[],
109 messageId: string
110): string[] {
111 const nodeMap = new Map<string, DatabaseMessage>();
112
113 // Build node map for quick lookups
114 for (const msg of messages) {
115 nodeMap.set(msg.id, msg);
116 }
117
118 const descendants: string[] = [];
119 const queue: string[] = [messageId];
120
121 while (queue.length > 0) {
122 const currentId = queue.shift()!;
123 const currentNode = nodeMap.get(currentId);
124
125 if (currentNode) {
126 // Add all children to the queue and descendants list
127 for (const childId of currentNode.children) {
128 descendants.push(childId);
129 queue.push(childId);
130 }
131 }
132 }
133
134 return descendants;
135}
136
137/**
138 * Gets sibling information for a message, including all sibling IDs and current position.
139 * Siblings are messages that share the same parent.
140 *
141 * @param messages - All messages in the conversation
142 * @param messageId - The message to get sibling info for
143 * @returns Sibling information including leaf node IDs for navigation
144 */
145export function getMessageSiblings(
146 messages: readonly DatabaseMessage[],
147 messageId: string
148): ChatMessageSiblingInfo | null {
149 const nodeMap = new Map<string, DatabaseMessage>();
150
151 // Build node map for quick lookups
152 for (const msg of messages) {
153 nodeMap.set(msg.id, msg);
154 }
155
156 const message = nodeMap.get(messageId);
157 if (!message) {
158 return null;
159 }
160
161 // Handle null parent (root message) case
162 if (message.parent === null) {
163 // No parent means this is likely a root node with no siblings
164 return {
165 message,
166 siblingIds: [messageId],
167 currentIndex: 0,
168 totalSiblings: 1
169 };
170 }
171
172 const parentNode = nodeMap.get(message.parent);
173 if (!parentNode) {
174 // Parent not found - treat as single message
175 return {
176 message,
177 siblingIds: [messageId],
178 currentIndex: 0,
179 totalSiblings: 1
180 };
181 }
182
183 // Get all sibling IDs (including self)
184 const siblingIds = parentNode.children;
185
186 // Convert sibling message IDs to their corresponding leaf node IDs
187 // This allows navigation between different conversation branches
188 const siblingLeafIds = siblingIds.map((siblingId: string) => findLeafNode(messages, siblingId));
189
190 // Find current message's position among siblings
191 const currentIndex = siblingIds.indexOf(messageId);
192
193 return {
194 message,
195 siblingIds: siblingLeafIds,
196 currentIndex,
197 totalSiblings: siblingIds.length
198 };
199}
200
201/**
202 * Creates a display-ready list of messages with sibling information for UI rendering.
203 * This is the main function used by chat components to render conversation branches.
204 *
205 * @param messages - All messages in the conversation
206 * @param leafNodeId - Current leaf node being viewed
207 * @returns Array of messages with sibling navigation info
208 */
209export function getMessageDisplayList(
210 messages: readonly DatabaseMessage[],
211 leafNodeId: string
212): ChatMessageSiblingInfo[] {
213 // Get the current conversation path
214 const currentPath = filterByLeafNodeId(messages, leafNodeId, true);
215 const result: ChatMessageSiblingInfo[] = [];
216
217 // Add sibling info for each message in the current path
218 for (const message of currentPath) {
219 if (message.type === 'root') {
220 continue; // Skip root messages in display
221 }
222
223 const siblingInfo = getMessageSiblings(messages, message.id);
224 if (siblingInfo) {
225 result.push(siblingInfo);
226 }
227 }
228
229 return result;
230}
231
232/**
233 * Checks if a message has multiple siblings (indicating branching at that point).
234 *
235 * @param messages - All messages in the conversation
236 * @param messageId - The message to check
237 * @returns True if the message has siblings
238 */
239export function hasMessageSiblings(
240 messages: readonly DatabaseMessage[],
241 messageId: string
242): boolean {
243 const siblingInfo = getMessageSiblings(messages, messageId);
244 return siblingInfo ? siblingInfo.totalSiblings > 1 : false;
245}
246
247/**
248 * Gets the next sibling message ID for navigation.
249 *
250 * @param messages - All messages in the conversation
251 * @param messageId - Current message ID
252 * @returns Next sibling's leaf node ID, or null if at the end
253 */
254export function getNextSibling(
255 messages: readonly DatabaseMessage[],
256 messageId: string
257): string | null {
258 const siblingInfo = getMessageSiblings(messages, messageId);
259 if (!siblingInfo || siblingInfo.currentIndex >= siblingInfo.totalSiblings - 1) {
260 return null;
261 }
262
263 return siblingInfo.siblingIds[siblingInfo.currentIndex + 1];
264}
265
266/**
267 * Gets the previous sibling message ID for navigation.
268 *
269 * @param messages - All messages in the conversation
270 * @param messageId - Current message ID
271 * @returns Previous sibling's leaf node ID, or null if at the beginning
272 */
273export function getPreviousSibling(
274 messages: readonly DatabaseMessage[],
275 messageId: string
276): string | null {
277 const siblingInfo = getMessageSiblings(messages, messageId);
278 if (!siblingInfo || siblingInfo.currentIndex <= 0) {
279 return null;
280 }
281
282 return siblingInfo.siblingIds[siblingInfo.currentIndex - 1];
283}
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 @@
1/**
2 * Browser-only utility exports
3 *
4 * These utilities require browser APIs (DOM, Canvas, MediaRecorder, etc.)
5 * and cannot be imported during SSR. Import from '$lib/utils/browser-only'
6 * only in client-side code or components that are not server-rendered.
7 */
8
9// Audio utilities (MediaRecorder API)
10export {
11 AudioRecorder,
12 convertToWav,
13 createAudioFile,
14 isAudioRecordingSupported
15} from './audio-recording';
16
17// PDF processing utilities (pdfjs-dist with DOMMatrix)
18export {
19 convertPDFToText,
20 convertPDFToImage,
21 isPdfFile as isPdfFileFromFile,
22 isApplicationMimeType
23} from './pdf-processing';
24
25// File conversion utilities (depends on pdf-processing)
26export { parseFilesToMessageExtras, type FileProcessingResult } from './convert-files-to-extra';
27
28// File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png)
29export { processFilesToChatUploaded } from './process-uploaded-files';
30
31// SVG utilities (Canvas/Image API)
32export { svgBase64UrlToPngDataURL, isSvgFile, isSvgMimeType } from './svg-to-png';
33
34// WebP utilities (Canvas/Image API)
35export { 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 @@
1import { toast } from 'svelte-sonner';
2import { AttachmentType } from '$lib/enums';
3import type {
4 DatabaseMessageExtra,
5 DatabaseMessageExtraTextFile,
6 DatabaseMessageExtraLegacyContext
7} from '$lib/types/database';
8
9/**
10 * Copy text to clipboard with toast notification
11 * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
12 * @param text - Text to copy to clipboard
13 * @param successMessage - Custom success message (optional)
14 * @param errorMessage - Custom error message (optional)
15 * @returns Promise<boolean> - True if successful, false otherwise
16 */
17export async function copyToClipboard(
18 text: string,
19 successMessage = 'Copied to clipboard',
20 errorMessage = 'Failed to copy to clipboard'
21): Promise<boolean> {
22 try {
23 // Try modern clipboard API first (secure contexts only)
24 if (navigator.clipboard && navigator.clipboard.writeText) {
25 await navigator.clipboard.writeText(text);
26 toast.success(successMessage);
27 return true;
28 }
29
30 // Fallback for non-secure contexts
31 const textArea = document.createElement('textarea');
32 textArea.value = text;
33 textArea.style.position = 'fixed';
34 textArea.style.left = '-999999px';
35 textArea.style.top = '-999999px';
36 document.body.appendChild(textArea);
37 textArea.focus();
38 textArea.select();
39
40 const successful = document.execCommand('copy');
41 document.body.removeChild(textArea);
42
43 if (successful) {
44 toast.success(successMessage);
45 return true;
46 } else {
47 throw new Error('execCommand failed');
48 }
49 } catch (error) {
50 console.error('Failed to copy to clipboard:', error);
51 toast.error(errorMessage);
52 return false;
53 }
54}
55
56/**
57 * Copy code with HTML entity decoding and toast notification
58 * @param rawCode - Raw code string that may contain HTML entities
59 * @param successMessage - Custom success message (optional)
60 * @param errorMessage - Custom error message (optional)
61 * @returns Promise<boolean> - True if successful, false otherwise
62 */
63export async function copyCodeToClipboard(
64 rawCode: string,
65 successMessage = 'Code copied to clipboard',
66 errorMessage = 'Failed to copy code'
67): Promise<boolean> {
68 return copyToClipboard(rawCode, successMessage, errorMessage);
69}
70
71/**
72 * Format for text attachments when copied to clipboard
73 */
74export interface ClipboardTextAttachment {
75 type: typeof AttachmentType.TEXT;
76 name: string;
77 content: string;
78}
79
80/**
81 * Parsed result from clipboard content
82 */
83export interface ParsedClipboardContent {
84 message: string;
85 textAttachments: ClipboardTextAttachment[];
86}
87
88/**
89 * Formats a message with text attachments for clipboard copying.
90 *
91 * Default format (asPlainText = false):
92 * ```
93 * "Text message content"
94 * [
95 * {"type":"TEXT","name":"filename.txt","content":"..."},
96 * {"type":"TEXT","name":"another.txt","content":"..."}
97 * ]
98 * ```
99 *
100 * Plain text format (asPlainText = true):
101 * ```
102 * Text message content
103 *
104 * file content here
105 *
106 * another file content
107 * ```
108 *
109 * @param content - The message text content
110 * @param extras - Optional array of message attachments
111 * @param asPlainText - If true, format as plain text without JSON structure
112 * @returns Formatted string for clipboard
113 */
114export function formatMessageForClipboard(
115 content: string,
116 extras?: DatabaseMessageExtra[],
117 asPlainText: boolean = false
118): string {
119 // Filter only text attachments (TEXT type and legacy CONTEXT type)
120 const textAttachments =
121 extras?.filter(
122 (extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
123 extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
124 ) ?? [];
125
126 if (textAttachments.length === 0) {
127 return content;
128 }
129
130 if (asPlainText) {
131 const parts = [content];
132 for (const att of textAttachments) {
133 parts.push(att.content);
134 }
135 return parts.join('\n\n');
136 }
137
138 const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({
139 type: AttachmentType.TEXT,
140 name: att.name,
141 content: att.content
142 }));
143
144 return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
145}
146
147/**
148 * Parses clipboard content to extract message and text attachments.
149 * Supports both plain text and the special format with attachments.
150 *
151 * @param clipboardText - Raw text from clipboard
152 * @returns Parsed content with message and attachments
153 */
154export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
155 const defaultResult: ParsedClipboardContent = {
156 message: clipboardText,
157 textAttachments: []
158 };
159
160 if (!clipboardText.startsWith('"')) {
161 return defaultResult;
162 }
163
164 try {
165 let stringEndIndex = -1;
166 let escaped = false;
167
168 for (let i = 1; i < clipboardText.length; i++) {
169 const char = clipboardText[i];
170
171 if (escaped) {
172 escaped = false;
173 continue;
174 }
175
176 if (char === '\\') {
177 escaped = true;
178 continue;
179 }
180
181 if (char === '"') {
182 stringEndIndex = i;
183 break;
184 }
185 }
186
187 if (stringEndIndex === -1) {
188 return defaultResult;
189 }
190
191 const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1);
192 const remainingPart = clipboardText.substring(stringEndIndex + 1).trim();
193
194 const message = JSON.parse(jsonStringPart) as string;
195
196 if (!remainingPart || !remainingPart.startsWith('[')) {
197 return {
198 message,
199 textAttachments: []
200 };
201 }
202
203 const attachments = JSON.parse(remainingPart) as unknown[];
204
205 const validAttachments: ClipboardTextAttachment[] = [];
206
207 for (const att of attachments) {
208 if (isValidTextAttachment(att)) {
209 validAttachments.push({
210 type: AttachmentType.TEXT,
211 name: att.name,
212 content: att.content
213 });
214 }
215 }
216
217 return {
218 message,
219 textAttachments: validAttachments
220 };
221 } catch {
222 return defaultResult;
223 }
224}
225
226/**
227 * Type guard to validate a text attachment object
228 * @param obj The object to validate
229 * @returns true if the object is a valid text attachment
230 */
231function isValidTextAttachment(
232 obj: unknown
233): obj is { type: string; name: string; content: string } {
234 if (typeof obj !== 'object' || obj === null) {
235 return false;
236 }
237
238 const record = obj as Record<string, unknown>;
239
240 return (
241 (record.type === AttachmentType.TEXT || record.type === 'TEXT') &&
242 typeof record.name === 'string' &&
243 typeof record.content === 'string'
244 );
245}
246
247/**
248 * Checks if clipboard content contains our special format with attachments
249 * @param clipboardText - Raw text from clipboard
250 * @returns true if the clipboard content contains our special format with attachments
251 */
252export function hasClipboardAttachments(clipboardText: string): boolean {
253 if (!clipboardText.startsWith('"')) {
254 return false;
255 }
256
257 const parsed = parseClipboardContent(clipboardText);
258 return parsed.textAttachments.length > 0;
259}
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 @@
1/**
2 * Type-safe configuration helpers
3 *
4 * Provides utilities for safely accessing and modifying configuration objects
5 * with dynamic keys while maintaining TypeScript type safety.
6 */
7
8/**
9 * Type-safe helper to access config properties dynamically
10 * Provides better type safety than direct casting to Record
11 */
12export function setConfigValue<T extends SettingsConfigType>(
13 config: T,
14 key: string,
15 value: unknown
16): void {
17 if (key in config) {
18 (config as Record<string, unknown>)[key] = value;
19 }
20}
21
22/**
23 * Type-safe helper to get config values dynamically
24 */
25export function getConfigValue<T extends SettingsConfigType>(
26 config: T,
27 key: string
28): string | number | boolean | undefined {
29 const value = (config as Record<string, unknown>)[key];
30 return value as string | number | boolean | undefined;
31}
32
33/**
34 * Convert a SettingsConfigType to a ParameterRecord for specific keys
35 * Useful for parameter synchronization operations
36 */
37export function configToParameterRecord<T extends SettingsConfigType>(
38 config: T,
39 keys: string[]
40): Record<string, string | number | boolean> {
41 const record: Record<string, string | number | boolean> = {};
42
43 for (const key of keys) {
44 const value = getConfigValue(config, key);
45 if (value !== undefined) {
46 record[key] = value;
47 }
48 }
49
50 return record;
51}
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 @@
1/**
2 * Utility functions for conversation data manipulation
3 */
4
5/**
6 * Creates a map of conversation IDs to their message counts from exported conversation data
7 * @param exportedData - Array of exported conversations with their messages
8 * @returns Map of conversation ID to message count
9 */
10export function createMessageCountMap(
11 exportedData: Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>
12): Map<string, number> {
13 const countMap = new Map<string, number>();
14
15 for (const item of exportedData) {
16 countMap.set(item.conv.id, item.messages.length);
17 }
18
19 return countMap;
20}
21
22/**
23 * Gets the message count for a specific conversation from the count map
24 * @param conversationId - The ID of the conversation
25 * @param countMap - Map of conversation IDs to message counts
26 * @returns The message count, or 0 if not found
27 */
28export function getMessageCount(conversationId: string, countMap: Map<string, number>): number {
29 return countMap.get(conversationId) ?? 0;
30}
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 @@
1import { convertPDFToImage, convertPDFToText } from './pdf-processing';
2import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
3import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
4import { FileTypeCategory, AttachmentType } from '$lib/enums';
5import { config, settingsStore } from '$lib/stores/settings.svelte';
6import { modelsStore } from '$lib/stores/models.svelte';
7import { getFileTypeCategory } from '$lib/utils';
8import { readFileAsText, isLikelyTextFile } from './text-files';
9import { toast } from 'svelte-sonner';
10
11function readFileAsBase64(file: File): Promise<string> {
12 return new Promise((resolve, reject) => {
13 const reader = new FileReader();
14
15 reader.onload = () => {
16 // Extract base64 data without the data URL prefix
17 const dataUrl = reader.result as string;
18 const base64 = dataUrl.split(',')[1];
19 resolve(base64);
20 };
21
22 reader.onerror = () => reject(reader.error);
23
24 reader.readAsDataURL(file);
25 });
26}
27
28export interface FileProcessingResult {
29 extras: DatabaseMessageExtra[];
30 emptyFiles: string[];
31}
32
33export async function parseFilesToMessageExtras(
34 files: ChatUploadedFile[],
35 activeModelId?: string
36): Promise<FileProcessingResult> {
37 const extras: DatabaseMessageExtra[] = [];
38 const emptyFiles: string[] = [];
39
40 for (const file of files) {
41 if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
42 if (file.preview) {
43 let base64Url = file.preview;
44
45 if (isSvgMimeType(file.type)) {
46 try {
47 base64Url = await svgBase64UrlToPngDataURL(base64Url);
48 } catch (error) {
49 console.error('Failed to convert SVG to PNG for database storage:', error);
50 }
51 } else if (isWebpMimeType(file.type)) {
52 try {
53 base64Url = await webpBase64UrlToPngDataURL(base64Url);
54 } catch (error) {
55 console.error('Failed to convert WebP to PNG for database storage:', error);
56 }
57 }
58
59 extras.push({
60 type: AttachmentType.IMAGE,
61 name: file.name,
62 base64Url
63 });
64 }
65 } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
66 // Process audio files (MP3 and WAV)
67 try {
68 const base64Data = await readFileAsBase64(file.file);
69
70 extras.push({
71 type: AttachmentType.AUDIO,
72 name: file.name,
73 base64Data: base64Data,
74 mimeType: file.type
75 });
76 } catch (error) {
77 console.error(`Failed to process audio file ${file.name}:`, error);
78 }
79 } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
80 try {
81 // Always get base64 data for preview functionality
82 const base64Data = await readFileAsBase64(file.file);
83 const currentConfig = config();
84 // Use per-model vision check for router mode
85 const hasVisionSupport = activeModelId
86 ? modelsStore.modelSupportsVision(activeModelId)
87 : false;
88
89 // Force PDF-to-text for non-vision models
90 let shouldProcessAsImages = Boolean(currentConfig.pdfAsImage) && hasVisionSupport;
91
92 // If user had pdfAsImage enabled but model doesn't support vision, update setting and notify
93 if (currentConfig.pdfAsImage && !hasVisionSupport) {
94 console.log('Non-vision model detected: forcing PDF-to-text mode and updating settings');
95
96 // Update the setting in localStorage
97 settingsStore.updateConfig('pdfAsImage', false);
98
99 // Show toast notification to user
100 toast.warning(
101 'PDF setting changed: Non-vision model detected, PDFs will be processed as text instead of images.',
102 {
103 duration: 5000
104 }
105 );
106
107 shouldProcessAsImages = false;
108 }
109
110 if (shouldProcessAsImages) {
111 // Process PDF as images (only for vision models)
112 try {
113 const images = await convertPDFToImage(file.file);
114
115 // Show success toast for PDF image processing
116 toast.success(
117 `PDF "${file.name}" processed as ${images.length} images for vision model.`,
118 {
119 duration: 3000
120 }
121 );
122
123 extras.push({
124 type: AttachmentType.PDF,
125 name: file.name,
126 content: `PDF file with ${images.length} pages`,
127 images: images,
128 processedAsImages: true,
129 base64Data: base64Data
130 });
131 } catch (imageError) {
132 console.warn(
133 `Failed to process PDF ${file.name} as images, falling back to text:`,
134 imageError
135 );
136
137 // Fallback to text processing
138 const content = await convertPDFToText(file.file);
139
140 extras.push({
141 type: AttachmentType.PDF,
142 name: file.name,
143 content: content,
144 processedAsImages: false,
145 base64Data: base64Data
146 });
147 }
148 } else {
149 // Process PDF as text (default or forced for non-vision models)
150 const content = await convertPDFToText(file.file);
151
152 // Show success toast for PDF text processing
153 toast.success(`PDF "${file.name}" processed as text content.`, {
154 duration: 3000
155 });
156
157 extras.push({
158 type: AttachmentType.PDF,
159 name: file.name,
160 content: content,
161 processedAsImages: false,
162 base64Data: base64Data
163 });
164 }
165 } catch (error) {
166 console.error(`Failed to process PDF file ${file.name}:`, error);
167 }
168 } else {
169 try {
170 const content = await readFileAsText(file.file);
171
172 // Check if file is empty
173 if (content.trim() === '') {
174 console.warn(`File ${file.name} is empty and will be skipped`);
175 emptyFiles.push(file.name);
176 } else if (isLikelyTextFile(content)) {
177 extras.push({
178 type: AttachmentType.TEXT,
179 name: file.name,
180 content: content
181 });
182 } else {
183 console.warn(`File ${file.name} appears to be binary and will be skipped`);
184 }
185 } catch (error) {
186 console.error(`Failed to read file ${file.name}:`, error);
187 }
188 }
189 }
190
191 return { extras, emptyFiles };
192}
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 @@
1/**
2 * Gets a display label for a file type from various input formats
3 *
4 * Handles:
5 * - MIME types: 'application/pdf' → 'PDF'
6 * - AttachmentType values: 'PDF', 'AUDIO' → 'PDF', 'AUDIO'
7 * - File names: 'document.pdf' → 'PDF'
8 * - Unknown: returns 'FILE'
9 *
10 * @param input - MIME type, AttachmentType value, or file name
11 * @returns Formatted file type label (uppercase)
12 */
13export function getFileTypeLabel(input: string | undefined): string {
14 if (!input) return 'FILE';
15
16 // Handle MIME types (contains '/')
17 if (input.includes('/')) {
18 const subtype = input.split('/').pop();
19 if (subtype) {
20 // Handle special cases like 'vnd.ms-excel' → 'EXCEL'
21 if (subtype.includes('.')) {
22 return subtype.split('.').pop()?.toUpperCase() || 'FILE';
23 }
24 return subtype.toUpperCase();
25 }
26 }
27
28 // Handle file names (contains '.')
29 if (input.includes('.')) {
30 const ext = input.split('.').pop();
31 if (ext) return ext.toUpperCase();
32 }
33
34 // Handle AttachmentType or other plain strings
35 return input.toUpperCase();
36}
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 @@
1import {
2 AUDIO_FILE_TYPES,
3 IMAGE_FILE_TYPES,
4 PDF_FILE_TYPES,
5 TEXT_FILE_TYPES
6} from '$lib/constants/supported-file-types';
7import {
8 FileExtensionAudio,
9 FileExtensionImage,
10 FileExtensionPdf,
11 FileExtensionText,
12 FileTypeCategory,
13 MimeTypeApplication,
14 MimeTypeAudio,
15 MimeTypeImage,
16 MimeTypeText
17} from '$lib/enums';
18
19export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
20 switch (mimeType) {
21 // Images
22 case MimeTypeImage.JPEG:
23 case MimeTypeImage.PNG:
24 case MimeTypeImage.GIF:
25 case MimeTypeImage.WEBP:
26 case MimeTypeImage.SVG:
27 return FileTypeCategory.IMAGE;
28
29 // Audio
30 case MimeTypeAudio.MP3_MPEG:
31 case MimeTypeAudio.MP3:
32 case MimeTypeAudio.MP4:
33 case MimeTypeAudio.WAV:
34 case MimeTypeAudio.WEBM:
35 case MimeTypeAudio.WEBM_OPUS:
36 return FileTypeCategory.AUDIO;
37
38 // PDF
39 case MimeTypeApplication.PDF:
40 return FileTypeCategory.PDF;
41
42 // Text
43 case MimeTypeText.PLAIN:
44 case MimeTypeText.MARKDOWN:
45 case MimeTypeText.ASCIIDOC:
46 case MimeTypeText.JAVASCRIPT:
47 case MimeTypeText.JAVASCRIPT_APP:
48 case MimeTypeText.TYPESCRIPT:
49 case MimeTypeText.JSX:
50 case MimeTypeText.TSX:
51 case MimeTypeText.CSS:
52 case MimeTypeText.HTML:
53 case MimeTypeText.JSON:
54 case MimeTypeText.XML_TEXT:
55 case MimeTypeText.XML_APP:
56 case MimeTypeText.YAML_TEXT:
57 case MimeTypeText.YAML_APP:
58 case MimeTypeText.CSV:
59 case MimeTypeText.PYTHON:
60 case MimeTypeText.JAVA:
61 case MimeTypeText.CPP_SRC:
62 case MimeTypeText.C_SRC:
63 case MimeTypeText.C_HDR:
64 case MimeTypeText.PHP:
65 case MimeTypeText.RUBY:
66 case MimeTypeText.GO:
67 case MimeTypeText.RUST:
68 case MimeTypeText.SHELL:
69 case MimeTypeText.BAT:
70 case MimeTypeText.SQL:
71 case MimeTypeText.R:
72 case MimeTypeText.SCALA:
73 case MimeTypeText.KOTLIN:
74 case MimeTypeText.SWIFT:
75 case MimeTypeText.DART:
76 case MimeTypeText.VUE:
77 case MimeTypeText.SVELTE:
78 case MimeTypeText.LATEX:
79 case MimeTypeText.BIBTEX:
80 case MimeTypeText.CUDA:
81 case MimeTypeText.CPP_HDR:
82 case MimeTypeText.CSHARP:
83 case MimeTypeText.HASKELL:
84 case MimeTypeText.PROPERTIES:
85 case MimeTypeText.TEX:
86 case MimeTypeText.TEX_APP:
87 return FileTypeCategory.TEXT;
88
89 default:
90 return null;
91 }
92}
93
94export function getFileTypeCategoryByExtension(filename: string): FileTypeCategory | null {
95 const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
96
97 switch (extension) {
98 // Images
99 case FileExtensionImage.JPG:
100 case FileExtensionImage.JPEG:
101 case FileExtensionImage.PNG:
102 case FileExtensionImage.GIF:
103 case FileExtensionImage.WEBP:
104 case FileExtensionImage.SVG:
105 return FileTypeCategory.IMAGE;
106
107 // Audio
108 case FileExtensionAudio.MP3:
109 case FileExtensionAudio.WAV:
110 return FileTypeCategory.AUDIO;
111
112 // PDF
113 case FileExtensionPdf.PDF:
114 return FileTypeCategory.PDF;
115
116 // Text
117 case FileExtensionText.TXT:
118 case FileExtensionText.MD:
119 case FileExtensionText.ADOC:
120 case FileExtensionText.JS:
121 case FileExtensionText.TS:
122 case FileExtensionText.JSX:
123 case FileExtensionText.TSX:
124 case FileExtensionText.CSS:
125 case FileExtensionText.HTML:
126 case FileExtensionText.HTM:
127 case FileExtensionText.JSON:
128 case FileExtensionText.XML:
129 case FileExtensionText.YAML:
130 case FileExtensionText.YML:
131 case FileExtensionText.CSV:
132 case FileExtensionText.LOG:
133 case FileExtensionText.PY:
134 case FileExtensionText.JAVA:
135 case FileExtensionText.CPP:
136 case FileExtensionText.C:
137 case FileExtensionText.H:
138 case FileExtensionText.PHP:
139 case FileExtensionText.RB:
140 case FileExtensionText.GO:
141 case FileExtensionText.RS:
142 case FileExtensionText.SH:
143 case FileExtensionText.BAT:
144 case FileExtensionText.SQL:
145 case FileExtensionText.R:
146 case FileExtensionText.SCALA:
147 case FileExtensionText.KT:
148 case FileExtensionText.SWIFT:
149 case FileExtensionText.DART:
150 case FileExtensionText.VUE:
151 case FileExtensionText.SVELTE:
152 case FileExtensionText.TEX:
153 case FileExtensionText.BIB:
154 case FileExtensionText.COMP:
155 case FileExtensionText.CU:
156 case FileExtensionText.CUH:
157 case FileExtensionText.HPP:
158 case FileExtensionText.HS:
159 case FileExtensionText.PROPERTIES:
160 return FileTypeCategory.TEXT;
161
162 default:
163 return null;
164 }
165}
166
167export function getFileTypeByExtension(filename: string): string | null {
168 const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
169
170 for (const [key, type] of Object.entries(IMAGE_FILE_TYPES)) {
171 if ((type.extensions as readonly string[]).includes(extension)) {
172 return `${FileTypeCategory.IMAGE}:${key}`;
173 }
174 }
175
176 for (const [key, type] of Object.entries(AUDIO_FILE_TYPES)) {
177 if ((type.extensions as readonly string[]).includes(extension)) {
178 return `${FileTypeCategory.AUDIO}:${key}`;
179 }
180 }
181
182 for (const [key, type] of Object.entries(PDF_FILE_TYPES)) {
183 if ((type.extensions as readonly string[]).includes(extension)) {
184 return `${FileTypeCategory.PDF}:${key}`;
185 }
186 }
187
188 for (const [key, type] of Object.entries(TEXT_FILE_TYPES)) {
189 if ((type.extensions as readonly string[]).includes(extension)) {
190 return `${FileTypeCategory.TEXT}:${key}`;
191 }
192 }
193
194 return null;
195}
196
197export function isFileTypeSupported(filename: string, mimeType?: string): boolean {
198 // Images are detected and handled separately for vision models
199 if (mimeType) {
200 const category = getFileTypeCategory(mimeType);
201 if (
202 category === FileTypeCategory.IMAGE ||
203 category === FileTypeCategory.AUDIO ||
204 category === FileTypeCategory.PDF
205 ) {
206 return true;
207 }
208 }
209
210 // Check extension for known types (especially images without MIME)
211 const extCategory = getFileTypeCategoryByExtension(filename);
212 if (
213 extCategory === FileTypeCategory.IMAGE ||
214 extCategory === FileTypeCategory.AUDIO ||
215 extCategory === FileTypeCategory.PDF
216 ) {
217 return true;
218 }
219
220 // Fallback: treat everything else as text (inclusive by default)
221 return true;
222}
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 @@
1/**
2 * Formats file size in bytes to human readable format
3 * Supports Bytes, KB, MB, and GB
4 *
5 * @param bytes - File size in bytes (or unknown for safety)
6 * @returns Formatted file size string
7 */
8export function formatFileSize(bytes: number | unknown): string {
9 if (typeof bytes !== 'number') return 'Unknown';
10 if (bytes === 0) return '0 Bytes';
11
12 const k = 1024;
13 const sizes = ['Bytes', 'KB', 'MB', 'GB'];
14 const i = Math.floor(Math.log(bytes) / Math.log(k));
15
16 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
17}
18
19/**
20 * Format parameter count to human-readable format (B, M, K)
21 *
22 * @param params - Parameter count
23 * @returns Human-readable parameter count
24 */
25export function formatParameters(params: number | unknown): string {
26 if (typeof params !== 'number') return 'Unknown';
27
28 if (params >= 1e9) {
29 return `${(params / 1e9).toFixed(2)}B`;
30 }
31
32 if (params >= 1e6) {
33 return `${(params / 1e6).toFixed(2)}M`;
34 }
35
36 if (params >= 1e3) {
37 return `${(params / 1e3).toFixed(2)}K`;
38 }
39
40 return params.toString();
41}
42
43/**
44 * Format number with locale-specific thousands separators
45 *
46 * @param num - Number to format
47 * @returns Human-readable number
48 */
49export function formatNumber(num: number | unknown): string {
50 if (typeof num !== 'number') return 'Unknown';
51
52 return num.toLocaleString();
53}
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 @@
1/**
2 * Unified exports for all utility functions
3 * Import utilities from '$lib/utils' for cleaner imports
4 *
5 * For browser-only utilities (pdf-processing, audio-recording, svg-to-png,
6 * webp-to-png, process-uploaded-files, convert-files-to-extra), use:
7 * import { ... } from '$lib/utils/browser-only'
8 */
9
10// API utilities
11export { getAuthHeaders, getJsonHeaders } from './api-headers';
12export { validateApiKey } from './api-key-validation';
13
14// Attachment utilities
15export {
16 getAttachmentDisplayItems,
17 type AttachmentDisplayItemsOptions
18} from './attachment-display';
19export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type';
20
21// Textarea utilities
22export { default as autoResizeTextarea } from './autoresize-textarea';
23
24// Branching utilities
25export {
26 filterByLeafNodeId,
27 findLeafNode,
28 findDescendantMessages,
29 getMessageSiblings,
30 getMessageDisplayList,
31 hasMessageSiblings,
32 getNextSibling,
33 getPreviousSibling
34} from './branching';
35
36// Config helpers
37export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
38
39// Conversation utilities
40export { createMessageCountMap, getMessageCount } from './conversation-utils';
41
42// Clipboard utilities
43export {
44 copyToClipboard,
45 copyCodeToClipboard,
46 formatMessageForClipboard,
47 parseClipboardContent,
48 hasClipboardAttachments,
49 type ClipboardTextAttachment,
50 type ParsedClipboardContent
51} from './clipboard';
52
53// File preview utilities
54export { getFileTypeLabel } from './file-preview';
55export { getPreviewText } from './text';
56
57// File type utilities
58export {
59 getFileTypeCategory,
60 getFileTypeCategoryByExtension,
61 getFileTypeByExtension,
62 isFileTypeSupported
63} from './file-type';
64
65// Formatting utilities
66export { formatFileSize, formatParameters, formatNumber } from './formatters';
67
68// IME utilities
69export { isIMEComposing } from './is-ime-composing';
70
71// LaTeX utilities
72export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection';
73
74// Modality file validation utilities
75export {
76 isFileTypeSupportedByModel,
77 filterFilesByModalities,
78 generateModalityErrorMessage,
79 type ModalityCapabilities
80} from './modality-file-validation';
81
82// Model name utilities
83export { normalizeModelName, isValidModelName } from './model-names';
84
85// Portal utilities
86export { portalToBody } from './portal-to-body';
87
88// Precision utilities
89export { normalizeFloatingPoint, normalizeNumber } from './precision';
90
91// Syntax highlighting utilities
92export { getLanguageFromFilename } from './syntax-highlight-language';
93
94// Text file utilities
95export { 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 @@
1export function isIMEComposing(event: KeyboardEvent) {
2 // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari, which is notorious for not supporting KeyboardEvent.isComposing)
3 // This prevents form submission when confirming IME word selection (e.g., Japanese/Chinese input)
4 return event.isComposing || event.keyCode === 229;
5}
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 @@
1import {
2 CODE_BLOCK_REGEXP,
3 LATEX_MATH_AND_CODE_PATTERN,
4 LATEX_LINEBREAK_REGEXP,
5 MHCHEM_PATTERN_MAP
6} from '$lib/constants/latex-protection';
7
8/**
9 * Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs
10 * that appear to be part of monetary values or identifiers.
11 *
12 * This function processes the input line by line and skips `$` sequences that are likely
13 * part of money amounts (e.g., `$5`, `$100.99`) or code-like tokens (e.g., `var$`, `$var`).
14 * Valid LaTeX inline math is replaced with a placeholder like `<<LATEX_0>>`, and the
15 * actual LaTeX content is stored in the provided `latexExpressions` array.
16 *
17 * @param content - The input text potentially containing LaTeX expressions.
18 * @param latexExpressions - An array used to collect extracted LaTeX expressions.
19 * @returns The processed string with LaTeX replaced by placeholders.
20 */
21export function maskInlineLaTeX(content: string, latexExpressions: string[]): string {
22 if (!content.includes('$')) {
23 return content;
24 }
25 return content
26 .split('\n')
27 .map((line) => {
28 if (line.indexOf('$') == -1) {
29 return line;
30 }
31
32 let processedLine = '';
33 let currentPosition = 0;
34
35 while (currentPosition < line.length) {
36 const openDollarIndex = line.indexOf('$', currentPosition);
37
38 if (openDollarIndex == -1) {
39 processedLine += line.slice(currentPosition);
40 break;
41 }
42
43 // Is there a next $-sign?
44 const closeDollarIndex = line.indexOf('$', openDollarIndex + 1);
45
46 if (closeDollarIndex == -1) {
47 processedLine += line.slice(currentPosition);
48 break;
49 }
50
51 const charBeforeOpen = openDollarIndex > 0 ? line[openDollarIndex - 1] : '';
52 const charAfterOpen = line[openDollarIndex + 1];
53 const charBeforeClose =
54 openDollarIndex + 1 < closeDollarIndex ? line[closeDollarIndex - 1] : '';
55 const charAfterClose = closeDollarIndex + 1 < line.length ? line[closeDollarIndex + 1] : '';
56
57 let shouldSkipAsNonLatex = false;
58
59 if (closeDollarIndex == currentPosition + 1) {
60 // No content
61 shouldSkipAsNonLatex = true;
62 }
63
64 if (/[A-Za-z0-9_$-]/.test(charBeforeOpen)) {
65 // Character, digit, $, _ or - before first '$', no TeX.
66 shouldSkipAsNonLatex = true;
67 }
68
69 if (
70 /[0-9]/.test(charAfterOpen) &&
71 (/[A-Za-z0-9_$-]/.test(charAfterClose) || ' ' == charBeforeClose)
72 ) {
73 // First $ seems to belong to an amount.
74 shouldSkipAsNonLatex = true;
75 }
76
77 if (shouldSkipAsNonLatex) {
78 processedLine += line.slice(currentPosition, openDollarIndex + 1);
79 currentPosition = openDollarIndex + 1;
80
81 continue;
82 }
83
84 // Treat as LaTeX
85 processedLine += line.slice(currentPosition, openDollarIndex);
86 const latexContent = line.slice(openDollarIndex, closeDollarIndex + 1);
87 latexExpressions.push(latexContent);
88 processedLine += `<<LATEX_${latexExpressions.length - 1}>>`;
89 currentPosition = closeDollarIndex + 1;
90 }
91
92 return processedLine;
93 })
94 .join('\n');
95}
96
97function escapeBrackets(text: string): string {
98 return text.replace(
99 LATEX_MATH_AND_CODE_PATTERN,
100 (
101 match: string,
102 codeBlock: string | undefined,
103 squareBracket: string | undefined,
104 roundBracket: string | undefined
105 ): string => {
106 if (codeBlock != null) {
107 return codeBlock;
108 } else if (squareBracket != null) {
109 return `$$${squareBracket}$$`;
110 } else if (roundBracket != null) {
111 return `$${roundBracket}$`;
112 }
113
114 return match;
115 }
116 );
117}
118
119// Escape $\\ce{...} → $\\ce{...} but with proper handling
120function escapeMhchem(text: string): string {
121 return MHCHEM_PATTERN_MAP.reduce((result, [pattern, replacement]) => {
122 return result.replace(pattern, replacement);
123 }, text);
124}
125
126const doEscapeMhchem = false;
127
128/**
129 * Preprocesses markdown content to safely handle LaTeX math expressions while protecting
130 * against false positives (e.g., dollar amounts like $5.99) and ensuring proper rendering.
131 *
132 * This function:
133 * - Protects code blocks (```) and inline code (`...`)
134 * - Safeguards block and inline LaTeX: \(...\), \[...\], $$...$$, and selective $...$
135 * - Escapes standalone dollar signs before numbers (e.g., $5 → \$5) to prevent misinterpretation
136 * - Restores protected LaTeX and code blocks after processing
137 * - Converts \(...\) → $...$ and \[...\] → $$...$$ for compatibility with math renderers
138 * - Applies additional escaping for brackets and mhchem syntax if needed
139 *
140 * @param content - The raw text (e.g., markdown) that may contain LaTeX or code blocks.
141 * @returns The preprocessed string with properly escaped and normalized LaTeX.
142 *
143 * @example
144 * preprocessLaTeX("Price: $10. The equation is \\(x^2\\).")
145 * // → "Price: $10. The equation is $x^2$."
146 */
147export function preprocessLaTeX(content: string): string {
148 // See also:
149 // https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
150
151 // Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly
152 // Store the structure so we can restore it later
153 const blockquoteMarkers: Map<number, string> = new Map();
154 const lines = content.split('\n');
155 const processedLines = lines.map((line, index) => {
156 const match = line.match(/^(>\s*)/);
157 if (match) {
158 blockquoteMarkers.set(index, match[1]);
159 return line.slice(match[1].length);
160 }
161 return line;
162 });
163 content = processedLines.join('\n');
164
165 // Step 1: Protect code blocks
166 const codeBlocks: string[] = [];
167
168 content = content.replace(CODE_BLOCK_REGEXP, (match) => {
169 codeBlocks.push(match);
170
171 return `<<CODE_BLOCK_${codeBlocks.length - 1}>>`;
172 });
173
174 // Step 2: Protect existing LaTeX expressions
175 const latexExpressions: string[] = [];
176
177 // Match \S...\[...\] and protect them and insert a line-break.
178 content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => {
179 // Check if there are characters following the formula (display-formula in a table-cell?)
180 if (group1.endsWith('\\')) {
181 return match; // Backslash before \[, do nothing.
182 }
183 const hasSuffix = /\S/.test(group3);
184 let optBreak;
185
186 if (hasSuffix) {
187 latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
188 optBreak = '';
189 } else {
190 latexExpressions.push(`\\[${group2}\\]`);
191 optBreak = '\n';
192 }
193
194 return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
195 });
196
197 // Match \(...\), \[...\], $$...$$ and protect them
198 content = content.replace(
199 /(\$\$[\s\S]*?\$\$|(?<!\\)\\\[[\s\S]*?\\\]|(?<!\\)\\\(.*?\\\))/g,
200 (match) => {
201 latexExpressions.push(match);
202
203 return `<<LATEX_${latexExpressions.length - 1}>>`;
204 }
205 );
206
207 // Protect inline $...$ but NOT if it looks like money (e.g., $10, $3.99)
208 content = maskInlineLaTeX(content, latexExpressions);
209
210 // Step 3: Escape standalone $ before digits (currency like $5 → \$5)
211 // (Now that inline math is protected, this will only escape dollars not already protected)
212 content = content.replace(/\$(?=\d)/g, '\\$');
213
214 // Step 4: Restore protected LaTeX expressions (they are valid)
215 content = content.replace(/<<LATEX_(\d+)>>/g, (_, index) => {
216 let expr = latexExpressions[parseInt(index)];
217 const match = expr.match(LATEX_LINEBREAK_REGEXP);
218 if (match) {
219 // Katex: The $$-delimiters should be in their own line
220 // if there are \\-line-breaks.
221 const formula = match[1];
222 const prefix = formula.startsWith('\n') ? '' : '\n';
223 const suffix = formula.endsWith('\n') ? '' : '\n';
224 expr = '$$' + prefix + formula + suffix + '$$';
225 }
226 return expr;
227 });
228
229 // Step 5: Apply additional escaping functions (brackets and mhchem)
230 // This must happen BEFORE restoring code blocks to avoid affecting code content
231 content = escapeBrackets(content);
232
233 if (doEscapeMhchem && (content.includes('\\ce{') || content.includes('\\pu{'))) {
234 content = escapeMhchem(content);
235 }
236
237 // Step 6: Convert remaining \(...\) → $...$, \[...\] → $$...$$
238 // This must happen BEFORE restoring code blocks to avoid affecting code content
239 content = content
240 // Using the look‑behind pattern `(?<!\\)` we skip matches
241 // that are preceded by a backslash, e.g.
242 // `Definitions\\(also called macros)` (title of chapter 20 in The TeXbook).
243 .replace(/(?<!\\)\\\((.+?)\\\)/g, '$$$1$') // inline
244 .replace(
245 // Using the look‑behind pattern `(?<!\\)` we skip matches
246 // that are preceded by a backslash, e.g. `\\[4pt]`.
247 /(?<!\\)\\\[([\s\S]*?)\\\]/g, // display, see also PR #16599
248 (_, content: string) => {
249 return `$$${content}$$`;
250 }
251 );
252
253 // Step 7: Restore code blocks
254 // This happens AFTER all LaTeX conversions to preserve code content
255 content = content.replace(/<<CODE_BLOCK_(\d+)>>/g, (_, index) => {
256 return codeBlocks[parseInt(index)];
257 });
258
259 // Step 8: Restore blockquote markers
260 if (blockquoteMarkers.size > 0) {
261 const finalLines = content.split('\n');
262 const restoredLines = finalLines.map((line, index) => {
263 const marker = blockquoteMarkers.get(index);
264 return marker ? marker + line : line;
265 });
266 content = restoredLines.join('\n');
267 }
268
269 return content;
270}
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 @@
1/**
2 * File validation utilities based on model modalities
3 * Ensures only compatible file types are processed based on model capabilities
4 */
5
6import { getFileTypeCategory } from '$lib/utils';
7import { FileTypeCategory } from '$lib/enums';
8
9/** Modality capabilities for file validation */
10export interface ModalityCapabilities {
11 hasVision: boolean;
12 hasAudio: boolean;
13}
14
15/**
16 * Check if a file type is supported by the given modalities
17 * @param filename - The filename to check
18 * @param mimeType - The MIME type of the file
19 * @param capabilities - The modality capabilities to check against
20 * @returns true if the file type is supported
21 */
22export function isFileTypeSupportedByModel(
23 filename: string,
24 mimeType: string | undefined,
25 capabilities: ModalityCapabilities
26): boolean {
27 const category = mimeType ? getFileTypeCategory(mimeType) : null;
28
29 // If we can't determine the category from MIME type, fall back to general support check
30 if (!category) {
31 // For unknown types, only allow if they might be text files
32 // This is a conservative approach for edge cases
33 return true; // Let the existing isFileTypeSupported handle this
34 }
35
36 switch (category) {
37 case FileTypeCategory.TEXT:
38 // Text files are always supported
39 return true;
40
41 case FileTypeCategory.PDF:
42 // PDFs are always supported (will be processed as text for non-vision models)
43 return true;
44
45 case FileTypeCategory.IMAGE:
46 // Images require vision support
47 return capabilities.hasVision;
48
49 case FileTypeCategory.AUDIO:
50 // Audio files require audio support
51 return capabilities.hasAudio;
52
53 default:
54 // Unknown categories - be conservative and allow
55 return true;
56 }
57}
58
59/**
60 * Filter files based on model modalities and return supported/unsupported lists
61 * @param files - Array of files to filter
62 * @param capabilities - The modality capabilities to check against
63 * @returns Object with supportedFiles and unsupportedFiles arrays
64 */
65export function filterFilesByModalities(
66 files: File[],
67 capabilities: ModalityCapabilities
68): {
69 supportedFiles: File[];
70 unsupportedFiles: File[];
71 modalityReasons: Record<string, string>;
72} {
73 const supportedFiles: File[] = [];
74 const unsupportedFiles: File[] = [];
75 const modalityReasons: Record<string, string> = {};
76
77 const { hasVision, hasAudio } = capabilities;
78
79 for (const file of files) {
80 const category = getFileTypeCategory(file.type);
81 let isSupported = true;
82 let reason = '';
83
84 switch (category) {
85 case FileTypeCategory.IMAGE:
86 if (!hasVision) {
87 isSupported = false;
88 reason = 'Images require a vision-capable model';
89 }
90 break;
91
92 case FileTypeCategory.AUDIO:
93 if (!hasAudio) {
94 isSupported = false;
95 reason = 'Audio files require an audio-capable model';
96 }
97 break;
98
99 case FileTypeCategory.TEXT:
100 case FileTypeCategory.PDF:
101 // Always supported
102 break;
103
104 default:
105 // For unknown types, check if it's a generally supported file type
106 // This handles edge cases and maintains backward compatibility
107 break;
108 }
109
110 if (isSupported) {
111 supportedFiles.push(file);
112 } else {
113 unsupportedFiles.push(file);
114 modalityReasons[file.name] = reason;
115 }
116 }
117
118 return { supportedFiles, unsupportedFiles, modalityReasons };
119}
120
121/**
122 * Generate a user-friendly error message for unsupported files
123 * @param unsupportedFiles - Array of unsupported files
124 * @param modalityReasons - Reasons why files are unsupported
125 * @param capabilities - The modality capabilities to check against
126 * @returns Formatted error message
127 */
128export function generateModalityErrorMessage(
129 unsupportedFiles: File[],
130 modalityReasons: Record<string, string>,
131 capabilities: ModalityCapabilities
132): string {
133 if (unsupportedFiles.length === 0) return '';
134
135 const { hasVision, hasAudio } = capabilities;
136
137 let message = '';
138
139 if (unsupportedFiles.length === 1) {
140 const file = unsupportedFiles[0];
141 const reason = modalityReasons[file.name];
142 message = `The file "${file.name}" cannot be uploaded: ${reason}.`;
143 } else {
144 const fileNames = unsupportedFiles.map((f) => f.name).join(', ');
145 message = `The following files cannot be uploaded: ${fileNames}.`;
146 }
147
148 // Add helpful information about what is supported
149 const supportedTypes: string[] = ['text files', 'PDFs'];
150 if (hasVision) supportedTypes.push('images');
151 if (hasAudio) supportedTypes.push('audio files');
152
153 message += ` This model supports: ${supportedTypes.join(', ')}.`;
154
155 return message;
156}
157
158/**
159 * Generate file input accept string based on model modalities
160 * @param capabilities - The modality capabilities to check against
161 * @returns Accept string for HTML file input element
162 */
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 @@
1/**
2 * Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format.
3 *
4 * Handles both forward slashes (/) and backslashes (\) as path separators.
5 * - If the model name has exactly one slash (org/model format), preserves the full "org/model" name
6 * - If the model name has no slash or multiple slashes, extracts just the filename
7 * - If the model name is just a filename (no path), returns it as-is.
8 *
9 * @param modelName - The model name or path to normalize
10 * @returns The normalized model name
11 *
12 * @example
13 * normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b' (multiple slashes -> filename)
14 * normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4' (multiple slashes -> filename)
15 * normalizeModelName('meta-llama/Llama-3.1-8B') // Returns: 'meta-llama/Llama-3.1-8B' (Hugging Face format)
16 * normalizeModelName('simple-model') // Returns: 'simple-model' (no slash)
17 * normalizeModelName(' spaced ') // Returns: 'spaced'
18 * normalizeModelName('') // Returns: ''
19 */
20export function normalizeModelName(modelName: string): string {
21 const trimmed = modelName.trim();
22
23 if (!trimmed) {
24 return '';
25 }
26
27 const segments = trimmed.split(/[\\/]/);
28
29 // If we have exactly 2 segments (one slash), treat it as Hugging Face repo format
30 // and preserve the full "org/model" format
31 if (segments.length === 2) {
32 const [org, model] = segments;
33 const trimmedOrg = org?.trim();
34 const trimmedModel = model?.trim();
35
36 if (trimmedOrg && trimmedModel) {
37 return `${trimmedOrg}/${trimmedModel}`;
38 }
39 }
40
41 // For other cases (no slash, or multiple slashes), extract just the filename
42 const candidate = segments.pop();
43 const normalized = candidate?.trim();
44
45 return normalized && normalized.length > 0 ? normalized : trimmed;
46}
47
48/**
49 * Validates if a model name is valid (non-empty after normalization).
50 *
51 * @param modelName - The model name to validate
52 * @returns true if valid, false otherwise
53 */
54export function isValidModelName(modelName: string): boolean {
55 return normalizeModelName(modelName).length > 0;
56}
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 @@
1/**
2 * PDF processing utilities using PDF.js
3 * Handles PDF text extraction and image conversion in the browser
4 */
5
6import { browser } from '$app/environment';
7import { MimeTypeApplication, MimeTypeImage } from '$lib/enums';
8import * as pdfjs from 'pdfjs-dist';
9
10type TextContent = {
11 items: Array<{ str: string }>;
12};
13
14if (browser) {
15 // Import worker as text and create blob URL for inline bundling
16 import('pdfjs-dist/build/pdf.worker.min.mjs?raw')
17 .then((workerModule) => {
18 const workerBlob = new Blob([workerModule.default], { type: 'application/javascript' });
19 pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(workerBlob);
20 })
21 .catch(() => {
22 console.warn('Failed to load PDF.js worker, PDF processing may not work');
23 });
24}
25
26/**
27 * Convert a File object to ArrayBuffer for PDF.js processing
28 * @param file - The PDF file to convert
29 * @returns Promise resolving to the file's ArrayBuffer
30 */
31async function getFileAsBuffer(file: File): Promise<ArrayBuffer> {
32 return new Promise((resolve, reject) => {
33 const reader = new FileReader();
34 reader.onload = (event) => {
35 if (event.target?.result) {
36 resolve(event.target.result as ArrayBuffer);
37 } else {
38 reject(new Error('Failed to read file.'));
39 }
40 };
41 reader.onerror = () => {
42 reject(new Error('Failed to read file.'));
43 };
44 reader.readAsArrayBuffer(file);
45 });
46}
47
48/**
49 * Extract text content from a PDF file
50 * @param file - The PDF file to process
51 * @returns Promise resolving to the extracted text content
52 */
53export async function convertPDFToText(file: File): Promise<string> {
54 if (!browser) {
55 throw new Error('PDF processing is only available in the browser');
56 }
57
58 try {
59 const buffer = await getFileAsBuffer(file);
60 const pdf = await pdfjs.getDocument(buffer).promise;
61 const numPages = pdf.numPages;
62
63 const textContentPromises: Promise<TextContent>[] = [];
64
65 for (let i = 1; i <= numPages; i++) {
66 // eslint-disable-next-line @typescript-eslint/no-explicit-any
67 textContentPromises.push(pdf.getPage(i).then((page: any) => page.getTextContent()));
68 }
69
70 const textContents = await Promise.all(textContentPromises);
71 const textItems = textContents.flatMap((textContent: TextContent) =>
72 textContent.items.map((item) => item.str ?? '')
73 );
74
75 return textItems.join('\n');
76 } catch (error) {
77 console.error('Error converting PDF to text:', error);
78 throw new Error(
79 `Failed to convert PDF to text: ${error instanceof Error ? error.message : 'Unknown error'}`
80 );
81 }
82}
83
84/**
85 * Convert PDF pages to PNG images as data URLs
86 * @param file - The PDF file to convert
87 * @param scale - Rendering scale factor (default: 1.5)
88 * @returns Promise resolving to array of PNG data URLs
89 */
90export async function convertPDFToImage(file: File, scale: number = 1.5): Promise<string[]> {
91 if (!browser) {
92 throw new Error('PDF processing is only available in the browser');
93 }
94
95 try {
96 const buffer = await getFileAsBuffer(file);
97 const doc = await pdfjs.getDocument(buffer).promise;
98 const pages: Promise<string>[] = [];
99
100 for (let i = 1; i <= doc.numPages; i++) {
101 const page = await doc.getPage(i);
102 const viewport = page.getViewport({ scale });
103 const canvas = document.createElement('canvas');
104 const ctx = canvas.getContext('2d');
105
106 canvas.width = viewport.width;
107 canvas.height = viewport.height;
108
109 if (!ctx) {
110 throw new Error('Failed to get 2D context from canvas');
111 }
112
113 const task = page.render({
114 canvasContext: ctx,
115 viewport: viewport,
116 canvas: canvas
117 });
118 pages.push(
119 task.promise.then(() => {
120 return canvas.toDataURL(MimeTypeImage.PNG);
121 })
122 );
123 }
124
125 return await Promise.all(pages);
126 } catch (error) {
127 console.error('Error converting PDF to images:', error);
128 throw new Error(
129 `Failed to convert PDF to images: ${error instanceof Error ? error.message : 'Unknown error'}`
130 );
131 }
132}
133
134/**
135 * Check if a file is a PDF based on its MIME type
136 * @param file - The file to check
137 * @returns True if the file is a PDF
138 */
139export function isPdfFile(file: File): boolean {
140 return file.type === MimeTypeApplication.PDF;
141}
142
143/**
144 * Check if a MIME type represents a PDF
145 * @param mimeType - The MIME type to check
146 * @returns True if the MIME type is application/pdf
147 */
148export function isApplicationMimeType(mimeType: string): boolean {
149 return mimeType === MimeTypeApplication.PDF;
150}
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 @@
1export function portalToBody(node: HTMLElement) {
2 if (typeof document === 'undefined') {
3 return;
4 }
5
6 const target = document.body;
7 if (!target) {
8 return;
9 }
10
11 target.appendChild(node);
12
13 return {
14 destroy() {
15 if (node.parentNode === target) {
16 target.removeChild(node);
17 }
18 }
19 };
20}
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 @@
1/**
2 * Floating-point precision utilities
3 *
4 * Provides functions to normalize floating-point numbers for consistent comparison
5 * and display, addressing JavaScript's floating-point precision issues.
6 */
7
8import { PRECISION_MULTIPLIER } from '$lib/constants/precision';
9
10/**
11 * Normalize floating-point numbers for consistent comparison
12 * Addresses JavaScript floating-point precision issues (e.g., 0.949999988079071 → 0.95)
13 */
14export function normalizeFloatingPoint(value: unknown): unknown {
15 return typeof value === 'number'
16 ? Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER
17 : value;
18}
19
20/**
21 * Type-safe version that only accepts numbers
22 */
23export function normalizeNumber(value: number): number {
24 return Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER;
25}
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 @@
1import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
2import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
3import { FileTypeCategory } from '$lib/enums';
4import { modelsStore } from '$lib/stores/models.svelte';
5import { settingsStore } from '$lib/stores/settings.svelte';
6import { toast } from 'svelte-sonner';
7import { getFileTypeCategory } from '$lib/utils';
8import { convertPDFToText } from './pdf-processing';
9
10/**
11 * Read a file as a data URL (base64 encoded)
12 * @param file - The file to read
13 * @returns Promise resolving to the data URL string
14 */
15function readFileAsDataURL(file: File): Promise<string> {
16 return new Promise((resolve, reject) => {
17 const reader = new FileReader();
18 reader.onload = () => resolve(reader.result as string);
19 reader.onerror = () => reject(reader.error);
20 reader.readAsDataURL(file);
21 });
22}
23
24/**
25 * Read a file as UTF-8 text
26 * @param file - The file to read
27 * @returns Promise resolving to the text content
28 */
29function readFileAsUTF8(file: File): Promise<string> {
30 return new Promise((resolve, reject) => {
31 const reader = new FileReader();
32 reader.onload = () => resolve(reader.result as string);
33 reader.onerror = () => reject(reader.error);
34 reader.readAsText(file);
35 });
36}
37
38/**
39 * Process uploaded files into ChatUploadedFile format with previews and content
40 *
41 * This function processes various file types and generates appropriate previews:
42 * - Images: Base64 data URLs with format normalization (SVG/WebP → PNG)
43 * - Text files: UTF-8 content extraction
44 * - PDFs: Metadata only (processed later in conversion pipeline)
45 * - Audio: Base64 data URLs for preview
46 *
47 * @param files - Array of File objects to process
48 * @returns Promise resolving to array of ChatUploadedFile objects
49 */
50export async function processFilesToChatUploaded(
51 files: File[],
52 activeModelId?: string
53): Promise<ChatUploadedFile[]> {
54 const results: ChatUploadedFile[] = [];
55
56 for (const file of files) {
57 const id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
58 const base: ChatUploadedFile = {
59 id,
60 name: file.name,
61 size: file.size,
62 type: file.type,
63 file
64 };
65
66 try {
67 if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
68 let preview = await readFileAsDataURL(file);
69
70 // Normalize SVG and WebP to PNG in previews
71 if (isSvgMimeType(file.type)) {
72 try {
73 preview = await svgBase64UrlToPngDataURL(preview);
74 } catch (err) {
75 console.error('Failed to convert SVG to PNG:', err);
76 }
77 } else if (isWebpMimeType(file.type)) {
78 try {
79 preview = await webpBase64UrlToPngDataURL(preview);
80 } catch (err) {
81 console.error('Failed to convert WebP to PNG:', err);
82 }
83 }
84
85 results.push({ ...base, preview });
86 } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
87 // Extract text content from PDF for preview
88 try {
89 const textContent = await convertPDFToText(file);
90 results.push({ ...base, textContent });
91 } catch (err) {
92 console.warn('Failed to extract text from PDF, adding without content:', err);
93 results.push(base);
94 }
95
96 // Show suggestion toast if vision model is available but PDF as image is disabled
97 const hasVisionSupport = activeModelId
98 ? modelsStore.modelSupportsVision(activeModelId)
99 : false;
100 const currentConfig = settingsStore.config;
101 if (hasVisionSupport && !currentConfig.pdfAsImage) {
102 toast.info(`You can enable parsing PDF as images with vision models.`, {
103 duration: 8000,
104 action: {
105 label: 'Enable PDF as Images',
106 onClick: () => {
107 settingsStore.updateConfig('pdfAsImage', true);
108 toast.success('PDF parsing as images enabled!', {
109 duration: 3000
110 });
111 }
112 }
113 });
114 }
115 } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
116 // Generate preview URL for audio files
117 const preview = await readFileAsDataURL(file);
118 results.push({ ...base, preview });
119 } else {
120 // Fallback: treat unknown files as text
121 try {
122 const textContent = await readFileAsUTF8(file);
123 results.push({ ...base, textContent });
124 } catch (err) {
125 console.warn('Failed to read file as text, adding without content:', err);
126 results.push(base);
127 }
128 }
129 } catch (error) {
130 console.error('Error processing file', file.name, error);
131 results.push(base);
132 }
133 }
134
135 return results;
136}
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 @@
1import { MimeTypeImage } from '$lib/enums';
2
3/**
4 * Convert an SVG base64 data URL to a PNG data URL
5 * @param base64UrlSvg - The SVG base64 data URL to convert
6 * @param backgroundColor - Background color for the PNG (default: 'white')
7 * @returns Promise resolving to PNG data URL
8 */
9export function svgBase64UrlToPngDataURL(
10 base64UrlSvg: string,
11 backgroundColor: string = 'white'
12): Promise<string> {
13 return new Promise((resolve, reject) => {
14 try {
15 const img = new Image();
16
17 img.onload = () => {
18 const canvas = document.createElement('canvas');
19 const ctx = canvas.getContext('2d');
20
21 if (!ctx) {
22 reject(new Error('Failed to get 2D canvas context.'));
23 return;
24 }
25
26 const targetWidth = img.naturalWidth || 300;
27 const targetHeight = img.naturalHeight || 300;
28
29 canvas.width = targetWidth;
30 canvas.height = targetHeight;
31
32 if (backgroundColor) {
33 ctx.fillStyle = backgroundColor;
34 ctx.fillRect(0, 0, canvas.width, canvas.height);
35 }
36 ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
37
38 resolve(canvas.toDataURL(MimeTypeImage.PNG));
39 };
40
41 img.onerror = () => {
42 reject(new Error('Failed to load SVG image. Ensure the SVG data is valid.'));
43 };
44
45 img.src = base64UrlSvg;
46 } catch (error) {
47 const message = error instanceof Error ? error.message : String(error);
48 const errorMessage = `Error converting SVG to PNG: ${message}`;
49 console.error(errorMessage, error);
50 reject(new Error(errorMessage));
51 }
52 });
53}
54
55/**
56 * Check if a file is an SVG based on its MIME type
57 * @param file - The file to check
58 * @returns True if the file is an SVG
59 */
60export function isSvgFile(file: File): boolean {
61 return file.type === MimeTypeImage.SVG;
62}
63
64/**
65 * Check if a MIME type represents an SVG
66 * @param mimeType - The MIME type to check
67 * @returns True if the MIME type is image/svg+xml
68 */
69export function isSvgMimeType(mimeType: string): boolean {
70 return mimeType === MimeTypeImage.SVG;
71}
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 @@
1/**
2 * Maps file extensions to highlight.js language identifiers
3 */
4export function getLanguageFromFilename(filename: string): string {
5 const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
6
7 switch (extension) {
8 // JavaScript / TypeScript
9 case '.js':
10 case '.mjs':
11 case '.cjs':
12 return 'javascript';
13 case '.ts':
14 case '.mts':
15 case '.cts':
16 return 'typescript';
17 case '.jsx':
18 return 'javascript';
19 case '.tsx':
20 return 'typescript';
21
22 // Web
23 case '.html':
24 case '.htm':
25 return 'html';
26 case '.css':
27 return 'css';
28 case '.scss':
29 return 'scss';
30 case '.less':
31 return 'less';
32 case '.vue':
33 return 'html';
34 case '.svelte':
35 return 'html';
36
37 // Data formats
38 case '.json':
39 return 'json';
40 case '.xml':
41 return 'xml';
42 case '.yaml':
43 case '.yml':
44 return 'yaml';
45 case '.toml':
46 return 'ini';
47 case '.csv':
48 return 'plaintext';
49
50 // Programming languages
51 case '.py':
52 return 'python';
53 case '.java':
54 return 'java';
55 case '.kt':
56 case '.kts':
57 return 'kotlin';
58 case '.scala':
59 return 'scala';
60 case '.cpp':
61 case '.cc':
62 case '.cxx':
63 case '.c++':
64 return 'cpp';
65 case '.c':
66 return 'c';
67 case '.h':
68 case '.hpp':
69 return 'cpp';
70 case '.cs':
71 return 'csharp';
72 case '.go':
73 return 'go';
74 case '.rs':
75 return 'rust';
76 case '.rb':
77 return 'ruby';
78 case '.php':
79 return 'php';
80 case '.swift':
81 return 'swift';
82 case '.dart':
83 return 'dart';
84 case '.r':
85 return 'r';
86 case '.lua':
87 return 'lua';
88 case '.pl':
89 case '.pm':
90 return 'perl';
91
92 // Shell
93 case '.sh':
94 case '.bash':
95 case '.zsh':
96 return 'bash';
97 case '.bat':
98 case '.cmd':
99 return 'dos';
100 case '.ps1':
101 return 'powershell';
102
103 // Database
104 case '.sql':
105 return 'sql';
106
107 // Markup / Documentation
108 case '.md':
109 case '.markdown':
110 return 'markdown';
111 case '.tex':
112 case '.latex':
113 return 'latex';
114 case '.adoc':
115 case '.asciidoc':
116 return 'asciidoc';
117
118 // Config
119 case '.ini':
120 case '.cfg':
121 case '.conf':
122 return 'ini';
123 case '.dockerfile':
124 return 'dockerfile';
125 case '.nginx':
126 return 'nginx';
127
128 // Other
129 case '.graphql':
130 case '.gql':
131 return 'graphql';
132 case '.proto':
133 return 'protobuf';
134 case '.diff':
135 case '.patch':
136 return 'diff';
137 case '.log':
138 return 'plaintext';
139 case '.txt':
140 return 'plaintext';
141
142 default:
143 return 'plaintext';
144 }
145}
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 @@
1/**
2 * Text file processing utilities
3 * Handles text file detection, reading, and validation
4 */
5
6import {
7 DEFAULT_BINARY_DETECTION_OPTIONS,
8 type BinaryDetectionOptions
9} from '$lib/constants/binary-detection';
10import { FileExtensionText } from '$lib/enums';
11
12/**
13 * Check if a filename indicates a text file based on its extension
14 * @param filename - The filename to check
15 * @returns True if the filename has a recognized text file extension
16 */
17export function isTextFileByName(filename: string): boolean {
18 const textExtensions = Object.values(FileExtensionText);
19
20 return textExtensions.some((ext: FileExtensionText) => filename.toLowerCase().endsWith(ext));
21}
22
23/**
24 * Read a file's content as text
25 * @param file - The file to read
26 * @returns Promise resolving to the file's text content
27 */
28export async function readFileAsText(file: File): Promise<string> {
29 return new Promise((resolve, reject) => {
30 const reader = new FileReader();
31
32 reader.onload = (event) => {
33 if (event.target?.result !== null && event.target?.result !== undefined) {
34 resolve(event.target.result as string);
35 } else {
36 reject(new Error('Failed to read file'));
37 }
38 };
39
40 reader.onerror = () => reject(new Error('File reading error'));
41
42 reader.readAsText(file);
43 });
44}
45
46/**
47 * Heuristic check to determine if content is likely from a text file
48 * Detects binary files by counting suspicious characters and null bytes
49 * @param content - The file content to analyze
50 * @param options - Optional configuration for detection parameters
51 * @returns True if the content appears to be text-based
52 */
53export function isLikelyTextFile(
54 content: string,
55 options: Partial<BinaryDetectionOptions> = {}
56): boolean {
57 if (!content) return true;
58
59 const config = { ...DEFAULT_BINARY_DETECTION_OPTIONS, ...options };
60 const sample = content.substring(0, config.prefixLength);
61
62 let nullCount = 0;
63 let suspiciousControlCount = 0;
64
65 for (let i = 0; i < sample.length; i++) {
66 const charCode = sample.charCodeAt(i);
67
68 // Count null bytes - these are strong indicators of binary files
69 if (charCode === 0) {
70 nullCount++;
71
72 continue;
73 }
74
75 // Count suspicious control characters
76 // Allow common whitespace characters: tab (9), newline (10), carriage return (13)
77 if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) {
78 // Count most suspicious control characters
79 if (charCode < 8 || (charCode > 13 && charCode < 27)) {
80 suspiciousControlCount++;
81 }
82 }
83
84 // Count replacement characters (indicates encoding issues)
85 if (charCode === 0xfffd) {
86 suspiciousControlCount++;
87 }
88 }
89
90 // Reject if too many null bytes
91 if (nullCount > config.maxAbsoluteNullBytes) return false;
92
93 // Reject if too many suspicious characters
94 if (suspiciousControlCount / sample.length > config.suspiciousCharThresholdRatio) return false;
95
96 return true;
97}
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 @@
1/**
2 * Returns a shortened preview of the provided content capped at the given length.
3 * Appends an ellipsis when the content exceeds the maximum.
4 */
5export function getPreviewText(content: string, max = 150): string {
6 return content.length > max ? content.slice(0, max) + '...' : content;
7}
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 @@
1import { FileExtensionImage, MimeTypeImage } from '$lib/enums';
2
3/**
4 * Convert a WebP base64 data URL to a PNG data URL
5 * @param base64UrlWebp - The WebP base64 data URL to convert
6 * @param backgroundColor - Background color for the PNG (default: 'white')
7 * @returns Promise resolving to PNG data URL
8 */
9export function webpBase64UrlToPngDataURL(
10 base64UrlWebp: string,
11 backgroundColor: string = 'white'
12): Promise<string> {
13 return new Promise((resolve, reject) => {
14 try {
15 const img = new Image();
16
17 img.onload = () => {
18 const canvas = document.createElement('canvas');
19 const ctx = canvas.getContext('2d');
20
21 if (!ctx) {
22 reject(new Error('Failed to get 2D canvas context.'));
23 return;
24 }
25
26 const targetWidth = img.naturalWidth || 300;
27 const targetHeight = img.naturalHeight || 300;
28
29 canvas.width = targetWidth;
30 canvas.height = targetHeight;
31
32 if (backgroundColor) {
33 ctx.fillStyle = backgroundColor;
34 ctx.fillRect(0, 0, canvas.width, canvas.height);
35 }
36 ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
37
38 resolve(canvas.toDataURL(MimeTypeImage.PNG));
39 };
40
41 img.onerror = () => {
42 reject(new Error('Failed to load WebP image. Ensure the WebP data is valid.'));
43 };
44
45 img.src = base64UrlWebp;
46 } catch (error) {
47 const message = error instanceof Error ? error.message : String(error);
48 const errorMessage = `Error converting WebP to PNG: ${message}`;
49 console.error(errorMessage, error);
50 reject(new Error(errorMessage));
51 }
52 });
53}
54
55/**
56 * Check if a file is a WebP based on its MIME type
57 * @param file - The file to check
58 * @returns True if the file is a WebP
59 */
60export function isWebpFile(file: File): boolean {
61 return (
62 file.type === MimeTypeImage.WEBP || file.name.toLowerCase().endsWith(FileExtensionImage.WEBP)
63 );
64}
65
66/**
67 * Check if a MIME type represents a WebP
68 * @param mimeType - The MIME type to check
69 * @returns True if the MIME type is image/webp
70 */
71export function isWebpMimeType(mimeType: string): boolean {
72 return mimeType === MimeTypeImage.WEBP;
73}