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