mirror of
https://github.com/ikawrakow/ik_llama.cpp.git
synced 2026-03-07 20:40:02 +00:00
Add --webui arg to launch llama.cpp new webui (#786)
* Add new webui from llama.cpp * Add new webui * feat: Improve mobile UI for Settings Dialog (#16084) * feat: Improve mobile UI for Settings Dialog * chore: update webui build output * fix: Linting errors * chore: update webui build output # Conflicts: # examples/server/webui_llamacpp/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte # examples/server/webui_llamacpp/src/lib/components/app/chat/ChatSettings/ChatSettingsSection.svelte # tools/server/public/index.html.gz * webui : fix handling incomplete chunks (#16107) * Always show message actions for mobile UI + improvements for user message sizing (#16076) # Conflicts: # .gitignore # examples/server/webui_llamacpp/package.json # examples/server/webui_llamacpp/scripts/dev.sh # tools/server/webui/scripts/post-build.sh * webui: switch to hash-based routing (alternative of #16079) (#16157) * Switched web UI to hash-based routing * Added hash to missed goto function call * Removed outdated SPA handling code * Fixed broken sidebar home link # Conflicts: # examples/server/webui_llamacpp/src/routes/+layout.ts # tools/server/server.cpp * Allow viewing conversations even when llama server is down (#16255) * webui: allow viewing conversations and sending messages even if llama-server is down - Cached llama.cpp server properties in browser localStorage on startup, persisting successful fetches and reloading them when refresh attempts fail so the chat UI continues to render while the backend is unavailable. - Cleared the stored server properties when resetting the store to prevent stale capability data after cache-backed operation. - Kept the original error-splash behavior when no cached props exist so fresh installs still surface a clear failure state instead of rendering stale data. * feat: Add UI for `props` endpoint unavailable + cleanup logic * webui: extend cached props fallback to offline errors Treat connection failures (refused, DNS, timeout, fetch) the same way as server 5xx so the warning banner shows up when cache is available, instead of falling back to a full error screen. * webui: Left the chat form enabled when a server warning is present so operators can keep sending messages e.g., to restart the backend over llama-swap, even while cached /props data is in use * chore: update webui build output --------- Co-authored-by: Pascal <admin@serveurperso.com> # Conflicts: # examples/server/webui_llamacpp/src/lib/components/app/chat/ChatScreen/ChatScreenWarning.svelte # examples/server/webui_llamacpp/src/lib/constants/localstorage-keys.ts * Enhance text file detection logic for file attachments (#16199) * feat: Enhances text file detection logic * chore: Build static `webui` output * chore: update webui build output # Conflicts: # examples/server/webui_llamacpp/src/lib/constants/binary-detection.ts * Show message actions by default (#16289) * fix: preserved zero values in chat settings inputs and textareas by switching to nullish coalescing for field values and default placeholders (#16312) * Improve Mobile UI for dialogs and action dropdowns (#16222) * fix: Always show conversation item actions * feat: Improve Alert Dialog and Dialog mobile UI * feat: Add settings reset to default confirmation * fix: Close Edit dialog on save * chore: update webui build output * webui: implement proper z-index system and scroll management - Add CSS variable for centralized z-index control - Fix dropdown positioning with Settings dialog conflicts - Prevent external scroll interference with proper event handling - Clean up hardcoded z-index values for maintainable architecture * webui: ensured the settings dialog enforces dynamic viewport height on mobile while retaining existing desktop sizing overrides * feat: Use `dvh` instead of computed px height for dialogs max height on mobile * chore: update webui build output * feat: Improve Settings fields UI * chore: update webui build output * chore: update webui build output --------- Co-authored-by: Pascal <admin@serveurperso.com> * Fix thinking blocks with quotes + add handling `[THINK]...[/THINK]` blocks (#16326) * fix: prevent reasoning blocks with quotes from being truncated * chore: update webui build output * feat: Improve thinking content parsing * test: Adds ChatMessage component stories for different thinking blocks * chore: update webui build output * fix: ChatMessage story fix --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Chatapi ignore empty sampling (#16330) * fix: skip empty sampling fields instead of coercing to 0 in chat API options * chore: update webui build output * webui: Remove running `llama-server` within WebUI `dev.sh` script (#16363) * Add optional setting for showing "Model used:" information (#16337) * feat: Add a setting to include model name used to generate the message * feat: UI improvements * feat: Save model info along with the database message entry creation * chore: Build webui static output * Improve code block color theming (#16325) * feat: Improve code block theming * chore: update webui build output * chore: Update webui static build * Conversation action dialogs as singletons from Chat Sidebar + apply conditional rendering for Actions Dropdown for Chat Conversation Items (#16369) * fix: Render Conversation action dialogs as singletons from Chat Sidebar level * chore: update webui build output * fix: Render Actions Dropdown conditionally only when user hovers conversation item + remove unused markup * chore: Update webui static build * fix: Always truncate conversation names * chore: Update webui static build * fix: track viewportHeight via window.innerHeight to avoid unwanted scrolling (#16356) Use <svelte:window bind:innerHeight> instead of manual resize listener Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * webui : Fix messages payload sent to chat completions (#16402) * fix: Include just the currently active message branches instead of all in chat completions request * chore: Build webui static output * chore: Formatting * chore: update webui build output * Capture model name only after first token (streaming) or completed request (#16405) * feat: Capture model name only after first token (streaming) or completed request (non-streaming) * chore: update webui build output * chore: update webui build output * Fix missing messages on sibling navigation (#16408) * fix: resolve message disappearing issue when navigating between regenerated siblings by using current leaf nodes instead of cached sibling IDs * chore: update webui build output * chore: update webui build output * webui : added download action (#13552) (#16282) * webui : added download action (#13552) * webui : import and export (for all conversations) * webui : fixed download-format, import of one conversation * webui : add ExportedConversations type for chat import/export * feat: Update naming & order * chore: Linting * webui : Updated static build output --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * refactor: centralize CoT parsing in backend for streaming mode (#16394) * refactor: unify reasoning handling via backend reasoning_content, drop frontend tag parsing - Updated the chat message component to surface backend-supplied reasoning via message.thinking while showing the raw assistant content without inline tag scrubbing - Simplified chat streaming to append content chunks directly, stream reasoning into the message model, and persist any partial reasoning when generation stops - Refactored the chat service SSE handler to rely on server-provided reasoning_content, removing legacy <think> parsing logic - Refreshed Storybook data and streaming flows to populate the thinking field explicitly for static and streaming assistant messages * refactor: implement streaming-aware universal reasoning parser Remove the streaming mode limitation from --reasoning-format by refactoring try_parse_reasoning() to handle incremental parsing of <think> tags across all formats. - Rework try_parse_reasoning() to track whitespace, partial tags, and multiple reasoning segments, allowing proper separation of reasoning_content and content in streaming mode - Parse reasoning tags before tool call handling in content-only and Llama 3.x formats to ensure inline <think> blocks are captured correctly - Change default reasoning_format from 'auto' to 'deepseek' for consistent behavior - Add 'deepseek-legacy' option to preserve old inline behavior when needed - Update CLI help and documentation to reflect streaming support - Add parser tests for inline <think>...</think> segments The parser now continues processing content after </think> closes instead of stopping, enabling proper message.reasoning_content and message.content separation in both streaming and non-streaming modes. Fixes the issue where streaming responses would dump everything (including post-thinking content) into reasoning_content while leaving content empty. * refactor: address review feedback from allozaur - Passed the assistant message content directly to ChatMessageAssistant to drop the redundant derived state in the chat message component - Simplified chat streaming updates by removing unused partial-thinking handling and persisting partial responses straight from currentResponse - Refreshed the ChatMessage stories to cover standard and reasoning scenarios without the old THINK-tag parsing examples Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * refactor: restore forced reasoning prefix to pass test-chat ([chat] All tests passed) - store the exact sequence seen on input when 'thinking_forced_open' enforces a reasoning block - inject this prefix before the first accumulated segment in 'reasoning_content', then clear it to avoid duplication - repeat the capture on every new 'start_think' detection to properly handle partial/streaming flows * refactor: address review feedback from ngxson * debug: say goodbye to curl -N, hello one-click raw stream - adds a new checkbox in the WebUI to display raw LLM output without backend parsing or frontend Markdown rendering * Update tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * webui: add Storybook example for raw LLM output and scope reasoning format toggle per story - Added a Storybook example that showcases the chat message component in raw LLM output mode with the provided trace sample - Updated every ChatMessage story to toggle the disableReasoningFormat setting so the raw-output rendering remains scoped to its own example * npm run format * chat-parser: address review feedback from ngxson Co-authored-by: Xuan Son Nguyen <thichthat@gmail.com> --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> Co-authored-by: Xuan Son Nguyen <thichthat@gmail.com> # Conflicts: # common/arg.cpp # examples/server/webui_llamacpp/src/lib/utils/thinking.ts # tools/server/README.md * No markdown in cot (#16483) * fix: let the model think in plaintext * chore: npm run format + npm run build * webui: updated the chat service to only include max_tokens in the req… (#16489) * webui: updated the chat service to only include max_tokens in the request payload when the setting is explicitly provided, while still mapping explicit zero or null values to the infinite-token sentinel * chore: update webui build output * feat: render user content as markdown option (#16358) * feat: render user content as markdown option - Add a persisted 'renderUserContentAsMarkdown' preference to the settings defaults and info metadata so the choice survives reloads like other options - Surface the new 'Render user content as Markdown' checkbox in the General section of the chat settings dialog, beneath the PDF toggle - Render user chat messages with 'MarkdownContent' when the new setting is enabled, matching assistant formatting while preserving the existing card styling otherwise - chore: update webui build output * chore: update webui build output * webui: remove client-side context pre-check and rely on backend for limits (#16506) * fix: make SSE client robust to premature [DONE] in agentic proxy chains * webui: remove client-side context pre-check and rely on backend for limits Removed the client-side context window pre-check and now simply sends messages while keeping the dialog imports limited to core components, eliminating the maximum context alert path Simplified streaming and non-streaming chat error handling to surface a generic 'No response received from server' error whenever the backend returns no content Removed the obsolete maxContextError plumbing from the chat store so state management now focuses on the core message flow without special context-limit cases * webui: cosmetic rename of error messages * Update tools/server/webui/src/lib/stores/chat.svelte.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/stores/chat.svelte.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * chore: update webui build output --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> # Conflicts: # examples/server/webui_llamacpp/src/lib/components/app/dialogs/ChatErrorDialog.svelte # examples/server/webui_llamacpp/src/lib/components/app/dialogs/MaximumContextAlertDialog.svelte # examples/server/webui_llamacpp/src/lib/services/context.ts * fix: add remark plugin to render raw HTML as literal text (#16505) * fix: add remark plugin to render raw HTML as literal text Implemented a missing MDAST stage to neutralize raw HTML like major LLM WebUIs do ensuring consistent and safe Markdown rendering Introduced 'remarkLiteralHtml', a plugin that converts raw HTML nodes in the Markdown AST into plain-text equivalents while preserving indentation and line breaks. This ensures consistent rendering and prevents unintended HTML execution, without altering valid Markdown structure Kept 'remarkRehype' in the pipeline since it performs the required conversion from MDAST to HAST for KaTeX, syntax highlighting, and HTML serialization Refined the link-enhancement logic to skip unnecessary DOM rewrites, fixing a subtle bug where extra paragraphs were injected after the first line due to full innerHTML reconstruction, and ensuring links open in new tabs only when required Final pipeline: remarkGfm -> remarkMath -> remarkBreaks -> remarkLiteralHtml -> remarkRehype -> rehypeKatex -> rehypeHighlight -> rehypeStringify * fix: address review feedback from allozaur * chore: update webui build output # Conflicts: # examples/server/webui_llamacpp/src/lib/constants/literal-html.ts * Add server-driven parameter defaults and syncing (#16515) # Conflicts: # examples/server/webui_llamacpp/src/lib/components/app/chat/ChatSettings/ParameterSourceIndicator.svelte # examples/server/webui_llamacpp/src/lib/constants/precision.ts # examples/server/webui_llamacpp/src/lib/services/parameter-sync.spec.ts # examples/server/webui_llamacpp/src/lib/services/parameter-sync.ts # examples/server/webui_llamacpp/src/lib/utils/config-helpers.ts # examples/server/webui_llamacpp/src/lib/utils/precision.ts * fix: added a normalization step for MathJax-style \[\] and \(\) delimiters (#16599) * fix: added a normalization step for MathJax-style \[\] and \(\) delimiters So inline and block equations are converted before KaTeX rendering, enabling proper display of model-generated LaTeX in the WebUI * chore: update webui build output * webui: reorganize settings layout (#16607) * webui: reorganize settings layout * chore: update webui build output * fix: remove unused variable * chore: update webui build output * Enable per-conversation loading states to allow having parallel conversations (#16327) * feat: Per-conversation loading states and tracking streaming stats * chore: update webui build output * refactor: Chat state management Consolidates loading state management by using a global `isLoading` store synchronized with individual conversation states. This change ensures proper reactivity and avoids potential race conditions when updating the UI based on the loading status of different conversations. It also improves the accuracy of statistics displayed. Additionally, slots service methods are updated to use conversation IDs for per-conversation state management, avoiding global state pollution. * feat: Adds loading indicator to conversation items * chore: update webui build output * fix: Fix aborting chat streaming Improves the chat stream abortion process by ensuring that partial responses are saved before the abort signal is sent. This avoids a race condition where the onError callback could clear the streaming state before the partial response is saved. Additionally, the stream reading loop and callbacks are now checked for abort signals to prevent further processing after abortion. * refactor: Remove redundant comments * chore: build webui static output * refactor: Cleanup * chore: update webui build output * chore: update webui build output * fix: Conversation loading indicator for regenerating messages * chore: update webui static build * feat: Improve configuration * feat: Install `http-server` as dev dependency to not need to rely on `npx` in CI * Import/Export UX improvements (#16619) * webui : added download action (#13552) * webui : import and export (for all conversations) * webui : fixed download-format, import of one conversation * webui : add ExportedConversations type for chat import/export * feat: Update naming & order * chore: Linting * feat: Import/Export UX improvements * chore: update webui build output * feat: Update UI placement of Import/Export tab in Chat Settings Dialog * refactor: Cleanup chore: update webui build output * feat: Enable shift-click multiple conversation items selection * chore: update webui static build * chore: update webui static build --------- Co-authored-by: Sascha Rogmann <github@rogmann.org> # Conflicts: # examples/server/webui_llamacpp/src/lib/components/app/chat/ChatSettings/ConversationSelectionDialog.svelte # examples/server/webui_llamacpp/src/lib/components/app/chat/ChatSettings/ImportExportTab.svelte # examples/server/webui_llamacpp/src/lib/utils/conversation-utils.ts * Prevent premature submission on IME input (#16673) * fix: Prevent premature submission on IME input * chore: update webui static build * refactor: Put IME completion checker in a helper function and add checking for `KeyboardEvent.eventKey === 229` * chore: update webui static build * chore: update webui static build * chore: update webui static build # Conflicts: # examples/server/webui_llamacpp/src/lib/utils/is-ime-composing.ts * Handle legacy 'context' attachments (#16687) * webui: introduce OpenAI-compatible model selector in JSON payload (#16562) * webui: introduce OpenAI-compatible model selector in JSON payload * webui: restore OpenAI-Compatible model source of truth and unify metadata capture This change re-establishes a single, reliable source of truth for the active model: fully aligned with the OpenAI-Compat API behavior It introduces a unified metadata flow that captures the model field from both streaming and non-streaming responses, wiring a new onModel callback through ChatService The model name is now resolved directly from the API payload rather than relying on server /props or UI assumptions ChatStore records and persists the resolved model for each assistant message during streaming, ensuring consistency across the UI and database Type definitions for API and settings were also extended to include model metadata and the onModel callback, completing the alignment with OpenAI-Compat semantics * webui: address review feedback from allozaur * webui: move model selector into ChatForm (idea by @allozaur) * webui: make model selector more subtle and integrated into ChatForm * webui: replaced the Flowbite selector with a native Svelte dropdown * webui: add developer setting to toggle the chat model selector * webui: address review feedback from allozaur Normalized streamed model names during chat updates by trimming input and removing directory components before saving or persisting them, so the conversation UI shows only the filename Forced model names within the chat form selector dropdown to render as a single-line, truncated entry with a tooltip revealing the full name * webui: toggle displayed model source for legacy vs OpenAI-Compat modes When the selector is disabled, it falls back to the active server model name from /props When the model selector is enabled, the displayed model comes from the message metadata (the one explicitly selected and sent in the request) * Update tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/constants/localstorage-keys.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormModelSelector.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/services/chat.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/services/chat.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * webui: refactor model selector and persistence helpers - Replace inline portal and event listeners with proper Svelte bindings - Introduce 'persisted' store helper for localStorage sync without runes - Extract 'normalizeModelName' utils + Vitest coverage - Simplify ChatFormModelSelector structure and cleanup logic Replaced the persisted store helper's use of '$state/$effect' runes with a plain TS implementation to prevent orphaned effect runtime errors outside component context Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * webui: document normalizeModelName usage with inline examples * Update tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormModelSelector.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/stores/models.svelte.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/stores/models.svelte.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * webui: extract ModelOption type into dedicated models.d.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * webui: refine ChatMessageAssistant displayedModel source logic * webui: stabilize dropdown, simplify model extraction, and init assistant model field * chore: update webui static build * Update tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * chore: npm format, update webui static build * webui: align sidebar trigger position, remove z-index glitch * chore: update webui build output --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> # Conflicts: # examples/server/webui_llamacpp/src/lib/components/app/chat/ChatForm/ChatFormModelSelector.svelte # examples/server/webui_llamacpp/src/lib/services/models.ts # examples/server/webui_llamacpp/src/lib/stores/models.svelte.ts # examples/server/webui_llamacpp/src/lib/stores/persisted.svelte.ts # examples/server/webui_llamacpp/src/lib/types/models.d.ts # examples/server/webui_llamacpp/src/lib/utils/model-names.test.ts # examples/server/webui_llamacpp/src/lib/utils/model-names.ts # examples/server/webui_llamacpp/src/lib/utils/portal-to-body.ts * webui: support q URL parameter (#16728) * webui: support q URL parameter Fixes #16722 I’ve checked that it works with Firefox’s AI tools * webui: apply suggestions from code review Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * chore: update webui static build --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * build fix --------- Co-authored-by: firecoperana <firecoperana> Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> Co-authored-by: Quentin Bramas <quentin.bramas@gmail.com> Co-authored-by: Isaac McFadyen <isaac@imcf.me> Co-authored-by: Pascal <admin@serveurperso.com> Co-authored-by: Sascha Rogmann <59577610+srogmann@users.noreply.github.com> Co-authored-by: Xuan Son Nguyen <thichthat@gmail.com> Co-authored-by: Sascha Rogmann <github@rogmann.org> Co-authored-by: Florian Badie <florianbadie@odrling.xyz>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
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');
|
||||
}
|
||||
|
||||
console.warn(`Server responded with status ${response.status} during API key validation`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// If it's already a SvelteKit error, re-throw it
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Network or other errors
|
||||
console.warn('Cannot connect to server for API key validation:', err);
|
||||
}
|
||||
}
|
||||
226
examples/server/webui_llamacpp/src/lib/utils/audio-recording.ts
Normal file
226
examples/server/webui_llamacpp/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
|
||||
);
|
||||
}
|
||||
@@ -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
examples/server/webui_llamacpp/src/lib/utils/branching.ts
Normal file
283
examples/server/webui_llamacpp/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];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Type-safe configuration helpers
|
||||
*
|
||||
* Provides utilities for safely accessing and modifying configuration objects
|
||||
* with dynamic keys while maintaining TypeScript type safety.
|
||||
*/
|
||||
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
|
||||
/**
|
||||
* Type-safe helper to access config properties dynamically
|
||||
* Provides better type safety than direct casting to Record
|
||||
*/
|
||||
export function setConfigValue<T extends SettingsConfigType>(
|
||||
config: T,
|
||||
key: string,
|
||||
value: unknown
|
||||
): void {
|
||||
if (key in config) {
|
||||
(config as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe helper to get config values dynamically
|
||||
*/
|
||||
export function getConfigValue<T extends SettingsConfigType>(
|
||||
config: T,
|
||||
key: string
|
||||
): string | number | boolean | undefined {
|
||||
const value = (config as Record<string, unknown>)[key];
|
||||
return value as string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a SettingsConfigType to a ParameterRecord for specific keys
|
||||
* Useful for parameter synchronization operations
|
||||
*/
|
||||
export function configToParameterRecord<T extends SettingsConfigType>(
|
||||
config: T,
|
||||
keys: string[]
|
||||
): Record<string, string | number | boolean> {
|
||||
const record: Record<string, string | number | boolean> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
const value = getConfigValue(config, key);
|
||||
if (value !== undefined) {
|
||||
record[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Utility functions for conversation data manipulation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a map of conversation IDs to their message counts from exported conversation data
|
||||
* @param exportedData - Array of exported conversations with their messages
|
||||
* @returns Map of conversation ID to message count
|
||||
*/
|
||||
export function createMessageCountMap(
|
||||
exportedData: Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>
|
||||
): Map<string, number> {
|
||||
const countMap = new Map<string, number>();
|
||||
|
||||
for (const item of exportedData) {
|
||||
countMap.set(item.conv.id, item.messages.length);
|
||||
}
|
||||
|
||||
return countMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message count for a specific conversation from the count map
|
||||
* @param conversationId - The ID of the conversation
|
||||
* @param countMap - Map of conversation IDs to message counts
|
||||
* @returns The message count, or 0 if not found
|
||||
*/
|
||||
export function getMessageCount(conversationId: string, countMap: Map<string, number>): number {
|
||||
return countMap.get(conversationId) ?? 0;
|
||||
}
|
||||
@@ -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
examples/server/webui_llamacpp/src/lib/utils/copy.ts
Normal file
71
examples/server/webui_llamacpp/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
examples/server/webui_llamacpp/src/lib/utils/file-preview.ts
Normal file
32
examples/server/webui_llamacpp/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
examples/server/webui_llamacpp/src/lib/utils/file-type.ts
Normal file
81
examples/server/webui_llamacpp/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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function isIMEComposing(event: KeyboardEvent) {
|
||||
// Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari, which is notorious for not supporting KeyboardEvent.isComposing)
|
||||
// This prevents form submission when confirming IME word selection (e.g., Japanese/Chinese input)
|
||||
return event.isComposing || event.keyCode === 229;
|
||||
}
|
||||
@@ -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(',');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isValidModelName, normalizeModelName } from './model-names';
|
||||
|
||||
describe('normalizeModelName', () => {
|
||||
it('extracts filename from forward slash path', () => {
|
||||
expect(normalizeModelName('models/model-name-1')).toBe('model-name-1');
|
||||
expect(normalizeModelName('path/to/model/model-name-2')).toBe('model-name-2');
|
||||
});
|
||||
|
||||
it('extracts filename from backslash path', () => {
|
||||
expect(normalizeModelName('C\\Models\\model-name-1')).toBe('model-name-1');
|
||||
expect(normalizeModelName('path\\to\\model\\model-name-2')).toBe('model-name-2');
|
||||
});
|
||||
|
||||
it('handles mixed path separators', () => {
|
||||
expect(normalizeModelName('path/to\\model/model-name-2')).toBe('model-name-2');
|
||||
});
|
||||
|
||||
it('returns simple names as-is', () => {
|
||||
expect(normalizeModelName('simple-model')).toBe('simple-model');
|
||||
expect(normalizeModelName('model-name-2')).toBe('model-name-2');
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(normalizeModelName(' model-name ')).toBe('model-name');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(normalizeModelName('')).toBe('');
|
||||
expect(normalizeModelName(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidModelName', () => {
|
||||
it('returns true for valid names', () => {
|
||||
expect(isValidModelName('model')).toBe(true);
|
||||
expect(isValidModelName('path/to/model.bin')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty values', () => {
|
||||
expect(isValidModelName('')).toBe(false);
|
||||
expect(isValidModelName(' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
39
examples/server/webui_llamacpp/src/lib/utils/model-names.ts
Normal file
39
examples/server/webui_llamacpp/src/lib/utils/model-names.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Normalizes a model name by extracting the filename from a path.
|
||||
*
|
||||
* Handles both forward slashes (/) and backslashes (\) as path separators.
|
||||
* If the model name is just a filename (no path), returns it as-is.
|
||||
*
|
||||
* @param modelName - The model name or path to normalize
|
||||
* @returns The normalized model name (filename only)
|
||||
*
|
||||
* @example
|
||||
* normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b'
|
||||
* normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4'
|
||||
* normalizeModelName('simple-model') // Returns: 'simple-model'
|
||||
* normalizeModelName(' spaced ') // Returns: 'spaced'
|
||||
* normalizeModelName('') // Returns: ''
|
||||
*/
|
||||
export function normalizeModelName(modelName: string): string {
|
||||
const trimmed = modelName.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const segments = trimmed.split(/[\\/]/);
|
||||
const candidate = segments.pop();
|
||||
const normalized = candidate?.trim();
|
||||
|
||||
return normalized && normalized.length > 0 ? normalized : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a model name is valid (non-empty after normalization).
|
||||
*
|
||||
* @param modelName - The model name to validate
|
||||
* @returns true if valid, false otherwise
|
||||
*/
|
||||
export function isValidModelName(modelName: string): boolean {
|
||||
return normalizeModelName(modelName).length > 0;
|
||||
}
|
||||
150
examples/server/webui_llamacpp/src/lib/utils/pdf-processing.ts
Normal file
150
examples/server/webui_llamacpp/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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function portalToBody(node: HTMLElement) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = document.body;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.appendChild(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
if (node.parentNode === target) {
|
||||
target.removeChild(node);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
25
examples/server/webui_llamacpp/src/lib/utils/precision.ts
Normal file
25
examples/server/webui_llamacpp/src/lib/utils/precision.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Floating-point precision utilities
|
||||
*
|
||||
* Provides functions to normalize floating-point numbers for consistent comparison
|
||||
* and display, addressing JavaScript's floating-point precision issues.
|
||||
*/
|
||||
|
||||
import { PRECISION_MULTIPLIER } from '$lib/constants/precision';
|
||||
|
||||
/**
|
||||
* Normalize floating-point numbers for consistent comparison
|
||||
* Addresses JavaScript floating-point precision issues (e.g., 0.949999988079071 → 0.95)
|
||||
*/
|
||||
export function normalizeFloatingPoint(value: unknown): unknown {
|
||||
return typeof value === 'number'
|
||||
? Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER
|
||||
: value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe version that only accepts numbers
|
||||
*/
|
||||
export function normalizeNumber(value: number): number {
|
||||
return Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER;
|
||||
}
|
||||
@@ -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
examples/server/webui_llamacpp/src/lib/utils/svg-to-png.ts
Normal file
71
examples/server/webui_llamacpp/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;
|
||||
}
|
||||
97
examples/server/webui_llamacpp/src/lib/utils/text-files.ts
Normal file
97
examples/server/webui_llamacpp/src/lib/utils/text-files.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Text file processing utilities
|
||||
* Handles text file detection, reading, and validation
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_BINARY_DETECTION_OPTIONS,
|
||||
type BinaryDetectionOptions
|
||||
} from '$lib/constants/binary-detection';
|
||||
import { FileExtensionText } from '$lib/enums/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
|
||||
* @param options - Optional configuration for detection parameters
|
||||
* @returns True if the content appears to be text-based
|
||||
*/
|
||||
export function isLikelyTextFile(
|
||||
content: string,
|
||||
options: Partial<BinaryDetectionOptions> = {}
|
||||
): boolean {
|
||||
if (!content) return true;
|
||||
|
||||
const config = { ...DEFAULT_BINARY_DETECTION_OPTIONS, ...options };
|
||||
const sample = content.substring(0, config.prefixLength);
|
||||
|
||||
let nullCount = 0;
|
||||
let suspiciousControlCount = 0;
|
||||
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const charCode = sample.charCodeAt(i);
|
||||
|
||||
// Count null bytes - these are strong indicators of binary files
|
||||
if (charCode === 0) {
|
||||
nullCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count suspicious control characters
|
||||
// Allow common whitespace characters: tab (9), newline (10), carriage return (13)
|
||||
if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) {
|
||||
// Count most suspicious control characters
|
||||
if (charCode < 8 || (charCode > 13 && charCode < 27)) {
|
||||
suspiciousControlCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Count replacement characters (indicates encoding issues)
|
||||
if (charCode === 0xfffd) {
|
||||
suspiciousControlCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject if too many null bytes
|
||||
if (nullCount > config.maxAbsoluteNullBytes) return false;
|
||||
|
||||
// Reject if too many suspicious characters
|
||||
if (suspiciousControlCount / sample.length > config.suspiciousCharThresholdRatio) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
73
examples/server/webui_llamacpp/src/lib/utils/webp-to-png.ts
Normal file
73
examples/server/webui_llamacpp/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