SvelteKit-based WebUI (#14839)
This commit is contained in:
committed by
GitHub
parent
8f8f2274ee
commit
a7a98e0fff
45
tools/server/webui/src/lib/utils/api-key-validation.ts
Normal file
45
tools/server/webui/src/lib/utils/api-key-validation.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
226
tools/server/webui/src/lib/utils/audio-recording.ts
Normal file
226
tools/server/webui/src/lib/utils/audio-recording.ts
Normal 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
|
||||
);
|
||||
}
|
||||
10
tools/server/webui/src/lib/utils/autoresize-textarea.ts
Normal file
10
tools/server/webui/src/lib/utils/autoresize-textarea.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
283
tools/server/webui/src/lib/utils/branching.ts
Normal file
283
tools/server/webui/src/lib/utils/branching.ts
Normal 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];
|
||||
}
|
||||
188
tools/server/webui/src/lib/utils/convert-files-to-extra.ts
Normal file
188
tools/server/webui/src/lib/utils/convert-files-to-extra.ts
Normal 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 };
|
||||
}
|
||||
71
tools/server/webui/src/lib/utils/copy.ts
Normal file
71
tools/server/webui/src/lib/utils/copy.ts
Normal 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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
return copyToClipboard(decodedCode, successMessage, errorMessage);
|
||||
}
|
||||
32
tools/server/webui/src/lib/utils/file-preview.ts
Normal file
32
tools/server/webui/src/lib/utils/file-preview.ts
Normal 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;
|
||||
}
|
||||
81
tools/server/webui/src/lib/utils/file-type.ts
Normal file
81
tools/server/webui/src/lib/utils/file-type.ts
Normal 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;
|
||||
}
|
||||
184
tools/server/webui/src/lib/utils/modality-file-validation.ts
Normal file
184
tools/server/webui/src/lib/utils/modality-file-validation.ts
Normal 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(',');
|
||||
}
|
||||
150
tools/server/webui/src/lib/utils/pdf-processing.ts
Normal file
150
tools/server/webui/src/lib/utils/pdf-processing.ts
Normal 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;
|
||||
}
|
||||
130
tools/server/webui/src/lib/utils/process-uploaded-files.ts
Normal file
130
tools/server/webui/src/lib/utils/process-uploaded-files.ts
Normal 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;
|
||||
}
|
||||
71
tools/server/webui/src/lib/utils/svg-to-png.ts
Normal file
71
tools/server/webui/src/lib/utils/svg-to-png.ts
Normal 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;
|
||||
}
|
||||
83
tools/server/webui/src/lib/utils/text-files.ts
Normal file
83
tools/server/webui/src/lib/utils/text-files.ts
Normal 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;
|
||||
}
|
||||
90
tools/server/webui/src/lib/utils/thinking.ts
Normal file
90
tools/server/webui/src/lib/utils/thinking.ts
Normal 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
|
||||
};
|
||||
}
|
||||
73
tools/server/webui/src/lib/utils/webp-to-png.ts
Normal file
73
tools/server/webui/src/lib/utils/webp-to-png.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user