SvelteKit-based WebUI (#14839)

This commit is contained in:
Aleksander Grygier
2025-09-17 19:29:13 +02:00
committed by GitHub
parent 8f8f2274ee
commit a7a98e0fff
288 changed files with 25749 additions and 11502 deletions

View File

@@ -0,0 +1,45 @@
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('/props', { headers });
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw error(401, 'Access denied');
} else if (response.status >= 500) {
throw error(response.status, 'Server error - check if llama.cpp server is running');
} else {
throw error(response.status, `Server responded with status ${response.status}`);
}
}
} 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
throw error(503, 'Cannot connect to server - check if llama.cpp server is running');
}
}

View File

@@ -0,0 +1,226 @@
import { MimeTypeAudio } from '$lib/enums/files';
/**
* 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
);
}

View File

@@ -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';
}
}

View File

@@ -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];
}

View File

@@ -0,0 +1,188 @@
import { convertPDFToImage, convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory } from '$lib/enums/files';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { supportsVision } from '$lib/stores/server.svelte';
import { getFileTypeCategory } from '$lib/utils/file-type';
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[]
): 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: 'imageFile',
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: 'audioFile',
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();
const hasVisionSupport = supportsVision();
// 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: 'pdfFile',
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: 'pdfFile',
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: 'pdfFile',
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: 'textFile',
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 };
}

View File

@@ -0,0 +1,71 @@
import { toast } from 'svelte-sonner';
/**
* 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> {
// Decode HTML entities
const decodedCode = rawCode
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
return copyToClipboard(decodedCode, successMessage, errorMessage);
}

View File

@@ -0,0 +1,32 @@
/**
* Formats file size in bytes to human readable format
* @param bytes - File size in bytes
* @returns Formatted file size string
*/
export function formatFileSize(bytes: number): string {
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];
}
/**
* Gets a display label for a file type
* @param fileType - The file type/mime type
* @returns Formatted file type label
*/
export function getFileTypeLabel(fileType: string): string {
return fileType.split('/').pop()?.toUpperCase() || 'FILE';
}
/**
* Truncates text content for preview display
* @param content - The text content to truncate
* @returns Truncated content with ellipsis if needed
*/
export function getPreviewText(content: string): string {
return content.length > 150 ? content.substring(0, 150) + '...' : content;
}

View File

@@ -0,0 +1,81 @@
import {
AUDIO_FILE_TYPES,
IMAGE_FILE_TYPES,
PDF_FILE_TYPES,
TEXT_FILE_TYPES
} from '$lib/constants/supported-file-types';
import { FileTypeCategory } from '$lib/enums/files';
export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
if (
Object.values(IMAGE_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.IMAGE;
}
if (
Object.values(AUDIO_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.AUDIO;
}
if (
Object.values(PDF_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.PDF;
}
if (
Object.values(TEXT_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.TEXT;
}
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 {
if (mimeType && getFileTypeCategory(mimeType)) {
return true;
}
return getFileTypeByExtension(filename) !== null;
}

View File

@@ -0,0 +1,184 @@
/**
* File validation utilities based on model modalities
* Ensures only compatible file types are processed based on model capabilities
*/
import { getFileTypeCategory } from '$lib/utils/file-type';
import { supportsVision, supportsAudio } from '$lib/stores/server.svelte';
import {
FileExtensionAudio,
FileExtensionImage,
FileExtensionPdf,
FileExtensionText,
MimeTypeAudio,
MimeTypeImage,
MimeTypeApplication,
MimeTypeText,
FileTypeCategory
} from '$lib/enums/files';
/**
* Check if a file type is supported by the current model's modalities
* @param filename - The filename to check
* @param mimeType - The MIME type of the file
* @returns true if the file type is supported by the current model
*/
export function isFileTypeSupportedByModel(filename: string, mimeType?: string): 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 supportsVision();
case FileTypeCategory.AUDIO:
// Audio files require audio support
return supportsAudio();
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
* @returns Object with supportedFiles and unsupportedFiles arrays
*/
export function filterFilesByModalities(files: File[]): {
supportedFiles: File[];
unsupportedFiles: File[];
modalityReasons: Record<string, string>;
} {
const supportedFiles: File[] = [];
const unsupportedFiles: File[] = [];
const modalityReasons: Record<string, string> = {};
const hasVision = supportsVision();
const hasAudio = supportsAudio();
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
* @returns Formatted error message
*/
export function generateModalityErrorMessage(
unsupportedFiles: File[],
modalityReasons: Record<string, string>
): string {
if (unsupportedFiles.length === 0) return '';
const hasVision = supportsVision();
const hasAudio = supportsAudio();
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 current model modalities
* @returns Accept string for HTML file input element
*/
export function generateModalityAwareAcceptString(): string {
const hasVision = supportsVision();
const hasAudio = supportsAudio();
const acceptedExtensions: string[] = [];
const acceptedMimeTypes: string[] = [];
// Always include text files and PDFs
acceptedExtensions.push(...Object.values(FileExtensionText));
acceptedMimeTypes.push(...Object.values(MimeTypeText));
acceptedExtensions.push(...Object.values(FileExtensionPdf));
acceptedMimeTypes.push(...Object.values(MimeTypeApplication));
// Include images only if vision is supported
if (hasVision) {
acceptedExtensions.push(...Object.values(FileExtensionImage));
acceptedMimeTypes.push(...Object.values(MimeTypeImage));
}
// Include audio only if audio is supported
if (hasAudio) {
acceptedExtensions.push(...Object.values(FileExtensionAudio));
acceptedMimeTypes.push(...Object.values(MimeTypeAudio));
}
return [...acceptedExtensions, ...acceptedMimeTypes].join(',');
}

View File

@@ -0,0 +1,150 @@
/**
* PDF processing utilities using PDF.js
* Handles PDF text extraction and image conversion in the browser
*/
import { browser } from '$app/environment';
import { MimeTypeApplication, MimeTypeImage } from '$lib/enums/files';
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;
}

View File

@@ -0,0 +1,130 @@
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isTextFileByName } from './text-files';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory } from '$lib/enums/files';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { supportsVision } from '$lib/stores/server.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from 'svelte-sonner';
/**
* 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[]): 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.TEXT ||
isTextFileByName(file.name)
) {
try {
const textContent = await readFileAsUTF8(file);
results.push({ ...base, textContent });
} catch (err) {
console.warn('Failed to read text file, adding without content:', err);
results.push(base);
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
// PDFs handled later when building extras; keep metadata only
results.push(base);
// Show suggestion toast if vision model is available but PDF as image is disabled
const hasVisionSupport = supportsVision();
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 {
// Other files: add as-is
results.push(base);
}
} catch (error) {
console.error('Error processing file', file.name, error);
results.push(base);
}
}
return results;
}

View File

@@ -0,0 +1,71 @@
import { MimeTypeImage } from '$lib/enums/files';
/**
* 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;
}

View File

@@ -0,0 +1,83 @@
/**
* Text file processing utilities
* Handles text file detection, reading, and validation
*/
import { FileExtensionText } from '$lib/enums/files';
/**
* 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
* @returns True if the content appears to be text-based
*/
export function isLikelyTextFile(content: string): boolean {
if (!content) return true;
const sample = content.substring(0, 1000);
let suspiciousCount = 0;
let nullCount = 0;
for (let i = 0; i < sample.length; i++) {
const charCode = sample.charCodeAt(i);
// Count null bytes
if (charCode === 0) {
nullCount++;
suspiciousCount++;
continue;
}
// Count suspicious control characters (excluding common ones like tab, newline, carriage return)
if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) {
suspiciousCount++;
}
// Count replacement characters (indicates encoding issues)
if (charCode === 0xfffd) {
suspiciousCount++;
}
}
// Reject if too many null bytes or suspicious characters
if (nullCount > 2) return false;
if (suspiciousCount / sample.length > 0.1) return false;
return true;
}

View File

@@ -0,0 +1,90 @@
/**
* Parses thinking content from a message that may contain <think> tags
* Returns an object with thinking content and cleaned message content
* Handles both complete <think>...</think> blocks and incomplete <think> blocks (streaming)
* @param content - The message content to parse
* @returns An object containing the extracted thinking content and the cleaned message content
*/
export function parseThinkingContent(content: string): {
thinking: string | null;
cleanContent: string;
} {
const incompleteMatch = content.includes('<think>') && !content.includes('</think>');
if (incompleteMatch) {
// Remove the entire <think>... part from clean content
const cleanContent = content.split('</think>')?.[1]?.trim();
// Extract everything after <think> as thinking content
const thinkingContent = content.split('<think>')?.[1]?.trim();
return {
cleanContent,
thinking: thinkingContent
};
}
const completeMatch = content.includes('</think>');
if (completeMatch) {
return {
thinking: content.split('</think>')?.[0]?.trim(),
cleanContent: content.split('</think>')?.[1]?.trim()
};
}
return {
thinking: null,
cleanContent: content
};
}
/**
* Checks if content contains an opening <think> tag (for streaming)
* @param content - The message content to check
* @returns True if the content contains an opening <think> tag
*/
export function hasThinkingStart(content: string): boolean {
return content.includes('<think>') || content.includes('<|channel|>analysis');
}
/**
* Checks if content contains a closing </think> tag (for streaming)
* @param content - The message content to check
* @returns True if the content contains a closing </think> tag
*/
export function hasThinkingEnd(content: string): boolean {
return content.includes('</think>');
}
/**
* Extracts partial thinking content during streaming
* Used when we have <think> but not yet </think>
* @param content - The message content to extract partial thinking from
* @returns An object containing the extracted partial thinking content and the remaining content
*/
export function extractPartialThinking(content: string): {
thinking: string | null;
remainingContent: string;
} {
const startIndex = content.indexOf('<think>');
if (startIndex === -1) {
return { thinking: null, remainingContent: content };
}
const endIndex = content.indexOf('</think>');
if (endIndex === -1) {
// Still streaming thinking content
const thinkingStart = startIndex + '<think>'.length;
return {
thinking: content.substring(thinkingStart),
remainingContent: content.substring(0, startIndex)
};
}
// Complete thinking block found
const parsed = parseThinkingContent(content);
return {
thinking: parsed.thinking,
remainingContent: parsed.cleanContent
};
}

View File

@@ -0,0 +1,73 @@
import { FileExtensionImage, MimeTypeImage } from '$lib/enums/files';
/**
* 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;
}