diff options
| author | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
|---|---|---|
| committer | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
| commit | b333b06772c89d96aacb5490d6a219fba7c09cc6 (patch) | |
| tree | 211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/utils | |
| download | llmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz | |
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/utils')
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 @@ | |||
| 1 | import { 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 | */ | ||
| 7 | export 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 | */ | ||
| 17 | export 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 @@ | |||
| 1 | import { base } from '$app/paths'; | ||
| 2 | import { error } from '@sveltejs/kit'; | ||
| 3 | import { browser } from '$app/environment'; | ||
| 4 | import { 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 | */ | ||
| 10 | export 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 @@ | |||
| 1 | import { FileTypeCategory } from '$lib/enums'; | ||
| 2 | import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils'; | ||
| 3 | |||
| 4 | export 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 | */ | ||
| 12 | function 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 | */ | ||
| 26 | export 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 @@ | |||
| 1 | import { AttachmentType, FileTypeCategory } from '$lib/enums'; | ||
| 2 | import { 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 | */ | ||
| 9 | function 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 | */ | ||
| 27 | export 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 | */ | ||
| 50 | export 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 | */ | ||
| 71 | export 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 | */ | ||
| 92 | export 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 @@ | |||
| 1 | import { 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 | */ | ||
| 16 | export 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 | |||
| 128 | export 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 | |||
| 152 | function 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 | */ | ||
| 203 | export 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 | */ | ||
| 218 | export 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 | */ | ||
| 5 | export 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 | */ | ||
| 27 | export 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 | */ | ||
| 81 | export 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 | */ | ||
| 107 | export 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 | */ | ||
| 145 | export 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 | */ | ||
| 209 | export 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 | */ | ||
| 239 | export 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 | */ | ||
| 254 | export 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 | */ | ||
| 273 | export 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) | ||
| 10 | export { | ||
| 11 | AudioRecorder, | ||
| 12 | convertToWav, | ||
| 13 | createAudioFile, | ||
| 14 | isAudioRecordingSupported | ||
| 15 | } from './audio-recording'; | ||
| 16 | |||
| 17 | // PDF processing utilities (pdfjs-dist with DOMMatrix) | ||
| 18 | export { | ||
| 19 | convertPDFToText, | ||
| 20 | convertPDFToImage, | ||
| 21 | isPdfFile as isPdfFileFromFile, | ||
| 22 | isApplicationMimeType | ||
| 23 | } from './pdf-processing'; | ||
| 24 | |||
| 25 | // File conversion utilities (depends on pdf-processing) | ||
| 26 | export { parseFilesToMessageExtras, type FileProcessingResult } from './convert-files-to-extra'; | ||
| 27 | |||
| 28 | // File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png) | ||
| 29 | export { processFilesToChatUploaded } from './process-uploaded-files'; | ||
| 30 | |||
| 31 | // SVG utilities (Canvas/Image API) | ||
| 32 | export { svgBase64UrlToPngDataURL, isSvgFile, isSvgMimeType } from './svg-to-png'; | ||
| 33 | |||
| 34 | // WebP utilities (Canvas/Image API) | ||
| 35 | export { webpBase64UrlToPngDataURL, isWebpFile, isWebpMimeType } from './webp-to-png'; | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts b/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts new file mode 100644 index 0000000..940e64c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts | |||
| @@ -0,0 +1,259 @@ | |||
| 1 | import { toast } from 'svelte-sonner'; | ||
| 2 | import { AttachmentType } from '$lib/enums'; | ||
| 3 | import 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 | */ | ||
| 17 | export 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 | */ | ||
| 63 | export 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 | */ | ||
| 74 | export interface ClipboardTextAttachment { | ||
| 75 | type: typeof AttachmentType.TEXT; | ||
| 76 | name: string; | ||
| 77 | content: string; | ||
| 78 | } | ||
| 79 | |||
| 80 | /** | ||
| 81 | * Parsed result from clipboard content | ||
| 82 | */ | ||
| 83 | export 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 | */ | ||
| 114 | export 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 | */ | ||
| 154 | export 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 | */ | ||
| 231 | function 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 | */ | ||
| 252 | export 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 | */ | ||
| 12 | export 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 | */ | ||
| 25 | export 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 | */ | ||
| 37 | export 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 | */ | ||
| 10 | export 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 | */ | ||
| 28 | export 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 @@ | |||
| 1 | import { convertPDFToImage, convertPDFToText } from './pdf-processing'; | ||
| 2 | import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png'; | ||
| 3 | import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png'; | ||
| 4 | import { FileTypeCategory, AttachmentType } from '$lib/enums'; | ||
| 5 | import { config, settingsStore } from '$lib/stores/settings.svelte'; | ||
| 6 | import { modelsStore } from '$lib/stores/models.svelte'; | ||
| 7 | import { getFileTypeCategory } from '$lib/utils'; | ||
| 8 | import { readFileAsText, isLikelyTextFile } from './text-files'; | ||
| 9 | import { toast } from 'svelte-sonner'; | ||
| 10 | |||
| 11 | function 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 | |||
| 28 | export interface FileProcessingResult { | ||
| 29 | extras: DatabaseMessageExtra[]; | ||
| 30 | emptyFiles: string[]; | ||
| 31 | } | ||
| 32 | |||
| 33 | export 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 | */ | ||
| 13 | export 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 @@ | |||
| 1 | import { | ||
| 2 | AUDIO_FILE_TYPES, | ||
| 3 | IMAGE_FILE_TYPES, | ||
| 4 | PDF_FILE_TYPES, | ||
| 5 | TEXT_FILE_TYPES | ||
| 6 | } from '$lib/constants/supported-file-types'; | ||
| 7 | import { | ||
| 8 | FileExtensionAudio, | ||
| 9 | FileExtensionImage, | ||
| 10 | FileExtensionPdf, | ||
| 11 | FileExtensionText, | ||
| 12 | FileTypeCategory, | ||
| 13 | MimeTypeApplication, | ||
| 14 | MimeTypeAudio, | ||
| 15 | MimeTypeImage, | ||
| 16 | MimeTypeText | ||
| 17 | } from '$lib/enums'; | ||
| 18 | |||
| 19 | export 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 | |||
| 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 | |||
| 94 | export 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 | |||
| 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 | |||
| 167 | export 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 | |||
| 197 | export 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 | */ | ||
| 8 | export 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 | */ | ||
| 25 | export 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 | */ | ||
| 49 | export 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 | ||
| 11 | export { getAuthHeaders, getJsonHeaders } from './api-headers'; | ||
| 12 | export { validateApiKey } from './api-key-validation'; | ||
| 13 | |||
| 14 | // Attachment utilities | ||
| 15 | export { | ||
| 16 | getAttachmentDisplayItems, | ||
| 17 | type AttachmentDisplayItemsOptions | ||
| 18 | } from './attachment-display'; | ||
| 19 | export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type'; | ||
| 20 | |||
| 21 | // Textarea utilities | ||
| 22 | export { default as autoResizeTextarea } from './autoresize-textarea'; | ||
| 23 | |||
| 24 | // Branching utilities | ||
| 25 | export { | ||
| 26 | filterByLeafNodeId, | ||
| 27 | findLeafNode, | ||
| 28 | findDescendantMessages, | ||
| 29 | getMessageSiblings, | ||
| 30 | getMessageDisplayList, | ||
| 31 | hasMessageSiblings, | ||
| 32 | getNextSibling, | ||
| 33 | getPreviousSibling | ||
| 34 | } from './branching'; | ||
| 35 | |||
| 36 | // Config helpers | ||
| 37 | export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers'; | ||
| 38 | |||
| 39 | // Conversation utilities | ||
| 40 | export { createMessageCountMap, getMessageCount } from './conversation-utils'; | ||
| 41 | |||
| 42 | // Clipboard utilities | ||
| 43 | export { | ||
| 44 | copyToClipboard, | ||
| 45 | copyCodeToClipboard, | ||
| 46 | formatMessageForClipboard, | ||
| 47 | parseClipboardContent, | ||
| 48 | hasClipboardAttachments, | ||
| 49 | type ClipboardTextAttachment, | ||
| 50 | type ParsedClipboardContent | ||
| 51 | } from './clipboard'; | ||
| 52 | |||
| 53 | // File preview utilities | ||
| 54 | export { getFileTypeLabel } from './file-preview'; | ||
| 55 | export { getPreviewText } from './text'; | ||
| 56 | |||
| 57 | // File type utilities | ||
| 58 | export { | ||
| 59 | getFileTypeCategory, | ||
| 60 | getFileTypeCategoryByExtension, | ||
| 61 | getFileTypeByExtension, | ||
| 62 | isFileTypeSupported | ||
| 63 | } from './file-type'; | ||
| 64 | |||
| 65 | // Formatting utilities | ||
| 66 | export { formatFileSize, formatParameters, formatNumber } from './formatters'; | ||
| 67 | |||
| 68 | // IME utilities | ||
| 69 | export { isIMEComposing } from './is-ime-composing'; | ||
| 70 | |||
| 71 | // LaTeX utilities | ||
| 72 | export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection'; | ||
| 73 | |||
| 74 | // Modality file validation utilities | ||
| 75 | export { | ||
| 76 | isFileTypeSupportedByModel, | ||
| 77 | filterFilesByModalities, | ||
| 78 | generateModalityErrorMessage, | ||
| 79 | type ModalityCapabilities | ||
| 80 | } from './modality-file-validation'; | ||
| 81 | |||
| 82 | // Model name utilities | ||
| 83 | export { normalizeModelName, isValidModelName } from './model-names'; | ||
| 84 | |||
| 85 | // Portal utilities | ||
| 86 | export { portalToBody } from './portal-to-body'; | ||
| 87 | |||
| 88 | // Precision utilities | ||
| 89 | export { normalizeFloatingPoint, normalizeNumber } from './precision'; | ||
| 90 | |||
| 91 | // Syntax highlighting utilities | ||
| 92 | export { getLanguageFromFilename } from './syntax-highlight-language'; | ||
| 93 | |||
| 94 | // Text file utilities | ||
| 95 | export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files'; | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts b/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts new file mode 100644 index 0000000..9182ea4 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | export 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 @@ | |||
| 1 | import { | ||
| 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 | */ | ||
| 21 | export 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 | |||
| 97 | function 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 | ||
| 120 | function escapeMhchem(text: string): string { | ||
| 121 | return MHCHEM_PATTERN_MAP.reduce((result, [pattern, replacement]) => { | ||
| 122 | return result.replace(pattern, replacement); | ||
| 123 | }, text); | ||
| 124 | } | ||
| 125 | |||
| 126 | const 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 | */ | ||
| 147 | export 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 | |||
| 6 | import { getFileTypeCategory } from '$lib/utils'; | ||
| 7 | import { FileTypeCategory } from '$lib/enums'; | ||
| 8 | |||
| 9 | /** Modality capabilities for file validation */ | ||
| 10 | export 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 | */ | ||
| 22 | export 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 | */ | ||
| 65 | export 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 | */ | ||
| 128 | export 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 | */ | ||
| 20 | export 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 | */ | ||
| 54 | export 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 | |||
| 6 | import { browser } from '$app/environment'; | ||
| 7 | import { MimeTypeApplication, MimeTypeImage } from '$lib/enums'; | ||
| 8 | import * as pdfjs from 'pdfjs-dist'; | ||
| 9 | |||
| 10 | type TextContent = { | ||
| 11 | items: Array<{ str: string }>; | ||
| 12 | }; | ||
| 13 | |||
| 14 | if (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 | */ | ||
| 31 | async 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 | */ | ||
| 53 | export 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 | */ | ||
| 90 | export 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 | */ | ||
| 139 | export 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 | */ | ||
| 148 | export 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 @@ | |||
| 1 | export 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 | |||
| 8 | import { 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 | */ | ||
| 14 | export 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 | */ | ||
| 23 | export 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 @@ | |||
| 1 | import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png'; | ||
| 2 | import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png'; | ||
| 3 | import { FileTypeCategory } from '$lib/enums'; | ||
| 4 | import { modelsStore } from '$lib/stores/models.svelte'; | ||
| 5 | import { settingsStore } from '$lib/stores/settings.svelte'; | ||
| 6 | import { toast } from 'svelte-sonner'; | ||
| 7 | import { getFileTypeCategory } from '$lib/utils'; | ||
| 8 | import { 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 | */ | ||
| 15 | function 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 | */ | ||
| 29 | function 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 | */ | ||
| 50 | export 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 @@ | |||
| 1 | import { 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 | */ | ||
| 9 | export 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 | */ | ||
| 60 | export 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 | */ | ||
| 69 | export 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 | */ | ||
| 4 | export 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 | |||
| 6 | import { | ||
| 7 | DEFAULT_BINARY_DETECTION_OPTIONS, | ||
| 8 | type BinaryDetectionOptions | ||
| 9 | } from '$lib/constants/binary-detection'; | ||
| 10 | import { 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 | */ | ||
| 17 | export 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 | */ | ||
| 28 | export 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 | */ | ||
| 53 | export 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 | */ | ||
| 5 | export 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 @@ | |||
| 1 | import { 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 | */ | ||
| 9 | export 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 | */ | ||
| 60 | export 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 | */ | ||
| 71 | export function isWebpMimeType(mimeType: string): boolean { | ||
| 72 | return mimeType === MimeTypeImage.WEBP; | ||
| 73 | } | ||
