]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
WebUI Architecture Cleanup (#19541)
authorAleksander Grygier <redacted>
Thu, 12 Feb 2026 10:22:27 +0000 (11:22 +0100)
committerGitHub <redacted>
Thu, 12 Feb 2026 10:22:27 +0000 (11:22 +0100)
* webui: architecture foundation (non-MCP core refactors)

* chore: update webui build output

40 files changed:
tools/server/public/index.html.gz
tools/server/webui/src/lib/constants/binary-detection.ts
tools/server/webui/src/lib/constants/chat-form.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/code-blocks.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/code.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/css-classes.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/formatters.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/markdown.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/processing-info.ts
tools/server/webui/src/lib/constants/settings-fields.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/tooltip-config.ts
tools/server/webui/src/lib/constants/ui.ts [new file with mode: 0644]
tools/server/webui/src/lib/contexts/chat-actions.context.ts [new file with mode: 0644]
tools/server/webui/src/lib/contexts/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/contexts/message-edit.context.ts [new file with mode: 0644]
tools/server/webui/src/lib/enums/chat.ts
tools/server/webui/src/lib/enums/keyboard.ts [new file with mode: 0644]
tools/server/webui/src/lib/enums/settings.ts [new file with mode: 0644]
tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts [new file with mode: 0644]
tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
tools/server/webui/src/lib/markdown/enhance-code-blocks.ts
tools/server/webui/src/lib/services/database.service.ts [new file with mode: 0644]
tools/server/webui/src/lib/services/models.service.ts [new file with mode: 0644]
tools/server/webui/src/lib/services/parameter-sync.service.spec.ts [new file with mode: 0644]
tools/server/webui/src/lib/services/parameter-sync.service.ts [new file with mode: 0644]
tools/server/webui/src/lib/services/props.service.ts [new file with mode: 0644]
tools/server/webui/src/lib/types/api.d.ts
tools/server/webui/src/lib/types/models.d.ts
tools/server/webui/src/lib/utils/abort.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/api-fetch.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/branching.ts
tools/server/webui/src/lib/utils/browser-only.ts
tools/server/webui/src/lib/utils/cache-ttl.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/code.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/data-url.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/debounce.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/image-error-fallback.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/index.ts
tools/server/webui/src/lib/utils/modality-file-validation.ts
tools/server/webui/src/lib/utils/text-files.ts

index e3b06f490103a30ef41f127d35275f11b1255e02..327386f413a633359ac0fe4fd25a7a42f15fe749 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index a4440fde5d4931778e3d641473232cac443c28f3..eac919ad9673d41dcb5cf60ec704593b32f8cfb2 100644 (file)
@@ -1,9 +1,6 @@
 export interface BinaryDetectionOptions {
-       /** Number of characters to check from the beginning of the file */
        prefixLength: number;
-       /** Maximum ratio of suspicious characters allowed (0.0 to 1.0) */
        suspiciousCharThresholdRatio: number;
-       /** Maximum absolute number of null bytes allowed */
        maxAbsoluteNullBytes: number;
 }
 
diff --git a/tools/server/webui/src/lib/constants/chat-form.ts b/tools/server/webui/src/lib/constants/chat-form.ts
new file mode 100644 (file)
index 0000000..c5e3dc3
--- /dev/null
@@ -0,0 +1,3 @@
+export const INITIAL_FILE_SIZE = 0;
+export const PROMPT_CONTENT_SEPARATOR = '\n\n';
+export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
diff --git a/tools/server/webui/src/lib/constants/code-blocks.ts b/tools/server/webui/src/lib/constants/code-blocks.ts
new file mode 100644 (file)
index 0000000..0f72651
--- /dev/null
@@ -0,0 +1,8 @@
+export const CODE_BLOCK_SCROLL_CONTAINER_CLASS = 'code-block-scroll-container';
+export const CODE_BLOCK_WRAPPER_CLASS = 'code-block-wrapper';
+export const CODE_BLOCK_HEADER_CLASS = 'code-block-header';
+export const CODE_BLOCK_ACTIONS_CLASS = 'code-block-actions';
+export const CODE_LANGUAGE_CLASS = 'code-language';
+export const COPY_CODE_BTN_CLASS = 'copy-code-btn';
+export const PREVIEW_CODE_BTN_CLASS = 'preview-code-btn';
+export const RELATIVE_CLASS = 'relative';
diff --git a/tools/server/webui/src/lib/constants/code.ts b/tools/server/webui/src/lib/constants/code.ts
new file mode 100644 (file)
index 0000000..12bcd0d
--- /dev/null
@@ -0,0 +1,7 @@
+export const NEWLINE = '\n';
+export const DEFAULT_LANGUAGE = 'text';
+export const LANG_PATTERN = /^(\w*)\n?/;
+export const AMPERSAND_REGEX = /&/g;
+export const LT_REGEX = /</g;
+export const GT_REGEX = />/g;
+export const FENCE_PATTERN = /^```|\n```/g;
diff --git a/tools/server/webui/src/lib/constants/css-classes.ts b/tools/server/webui/src/lib/constants/css-classes.ts
new file mode 100644 (file)
index 0000000..46076e5
--- /dev/null
@@ -0,0 +1,10 @@
+export const BOX_BORDER =
+       'border border-border/30 focus-within:border-border  dark:border-border/20 dark:focus-within:border-border';
+
+export const INPUT_CLASSES = `
+    bg-muted/60 dark:bg-muted/75
+    ${BOX_BORDER}
+    shadow-sm
+    outline-none
+    text-foreground
+`;
diff --git a/tools/server/webui/src/lib/constants/formatters.ts b/tools/server/webui/src/lib/constants/formatters.ts
new file mode 100644 (file)
index 0000000..d6d1b88
--- /dev/null
@@ -0,0 +1,8 @@
+export const MS_PER_SECOND = 1000;
+export const SECONDS_PER_MINUTE = 60;
+export const SECONDS_PER_HOUR = 3600;
+export const SHORT_DURATION_THRESHOLD = 1;
+export const MEDIUM_DURATION_THRESHOLD = 10;
+
+/** Default display value when no performance time is available */
+export const DEFAULT_PERFORMANCE_TIME = '0s';
diff --git a/tools/server/webui/src/lib/constants/markdown.ts b/tools/server/webui/src/lib/constants/markdown.ts
new file mode 100644 (file)
index 0000000..783d31a
--- /dev/null
@@ -0,0 +1,4 @@
+export const IMAGE_NOT_ERROR_BOUND_SELECTOR = 'img:not([data-error-bound])';
+export const DATA_ERROR_BOUND_ATTR = 'errorBound';
+export const DATA_ERROR_HANDLED_ATTR = 'errorHandled';
+export const BOOL_TRUE_STRING = 'true';
index 726439211b33fc143c50dc0905f337970d2d29c1..2c3f7dc53440614c4ab1ef98a517acb0e1a4b40f 100644 (file)
@@ -1 +1,8 @@
 export const PROCESSING_INFO_TIMEOUT = 2000;
+
+/**
+ * Statistics units labels
+ */
+export const STATS_UNITS = {
+       TOKENS_PER_SECOND: 't/s'
+} as const;
diff --git a/tools/server/webui/src/lib/constants/settings-fields.ts b/tools/server/webui/src/lib/constants/settings-fields.ts
new file mode 100644 (file)
index 0000000..79a6e92
--- /dev/null
@@ -0,0 +1,33 @@
+/**
+ * List of all numeric fields in settings configuration.
+ * These fields will be converted from strings to numbers during save.
+ */
+export const NUMERIC_FIELDS = [
+       'temperature',
+       'top_k',
+       'top_p',
+       'min_p',
+       'max_tokens',
+       'pasteLongTextToFileLen',
+       'dynatemp_range',
+       'dynatemp_exponent',
+       'typ_p',
+       'xtc_probability',
+       'xtc_threshold',
+       'repeat_last_n',
+       'repeat_penalty',
+       'presence_penalty',
+       'frequency_penalty',
+       'dry_multiplier',
+       'dry_base',
+       'dry_allowed_length',
+       'dry_penalty_last_n',
+       'agenticMaxTurns',
+       'agenticMaxToolPreviewLines'
+] as const;
+
+/**
+ * Fields that must be positive integers (>= 1).
+ * These will be clamped to minimum 1 and rounded during save.
+ */
+export const POSITIVE_INTEGER_FIELDS = ['agenticMaxTurns', 'agenticMaxToolPreviewLines'] as const;
index 3c30c8c0725a30c5729841c27d121c5f5cb73cf6..ad76ab35226615e8586535bdb3aabc64af860c21 100644 (file)
@@ -1 +1 @@
-export const TOOLTIP_DELAY_DURATION = 100;
+export const TOOLTIP_DELAY_DURATION = 500;
diff --git a/tools/server/webui/src/lib/constants/ui.ts b/tools/server/webui/src/lib/constants/ui.ts
new file mode 100644 (file)
index 0000000..a75b30f
--- /dev/null
@@ -0,0 +1 @@
+export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
diff --git a/tools/server/webui/src/lib/contexts/chat-actions.context.ts b/tools/server/webui/src/lib/contexts/chat-actions.context.ts
new file mode 100644 (file)
index 0000000..eba0fec
--- /dev/null
@@ -0,0 +1,34 @@
+import { getContext, setContext } from 'svelte';
+
+export interface ChatActionsContext {
+       copy: (message: DatabaseMessage) => void;
+       delete: (message: DatabaseMessage) => void;
+       navigateToSibling: (siblingId: string) => void;
+       editWithBranching: (
+               message: DatabaseMessage,
+               newContent: string,
+               newExtras?: DatabaseMessageExtra[]
+       ) => void;
+       editWithReplacement: (
+               message: DatabaseMessage,
+               newContent: string,
+               shouldBranch: boolean
+       ) => void;
+       editUserMessagePreserveResponses: (
+               message: DatabaseMessage,
+               newContent: string,
+               newExtras?: DatabaseMessageExtra[]
+       ) => void;
+       regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void;
+       continueAssistantMessage: (message: DatabaseMessage) => void;
+}
+
+const CHAT_ACTIONS_KEY = Symbol.for('chat-actions');
+
+export function setChatActionsContext(ctx: ChatActionsContext): ChatActionsContext {
+       return setContext(CHAT_ACTIONS_KEY, ctx);
+}
+
+export function getChatActionsContext(): ChatActionsContext {
+       return getContext(CHAT_ACTIONS_KEY);
+}
diff --git a/tools/server/webui/src/lib/contexts/index.ts b/tools/server/webui/src/lib/contexts/index.ts
new file mode 100644 (file)
index 0000000..73ff6f9
--- /dev/null
@@ -0,0 +1,13 @@
+export {
+       getMessageEditContext,
+       setMessageEditContext,
+       type MessageEditContext,
+       type MessageEditState,
+       type MessageEditActions
+} from './message-edit.context';
+
+export {
+       getChatActionsContext,
+       setChatActionsContext,
+       type ChatActionsContext
+} from './chat-actions.context';
diff --git a/tools/server/webui/src/lib/contexts/message-edit.context.ts b/tools/server/webui/src/lib/contexts/message-edit.context.ts
new file mode 100644 (file)
index 0000000..7af116d
--- /dev/null
@@ -0,0 +1,39 @@
+import { getContext, setContext } from 'svelte';
+
+export interface MessageEditState {
+       readonly isEditing: boolean;
+       readonly editedContent: string;
+       readonly editedExtras: DatabaseMessageExtra[];
+       readonly editedUploadedFiles: ChatUploadedFile[];
+       readonly originalContent: string;
+       readonly originalExtras: DatabaseMessageExtra[];
+       readonly showSaveOnlyOption: boolean;
+}
+
+export interface MessageEditActions {
+       setContent: (content: string) => void;
+       setExtras: (extras: DatabaseMessageExtra[]) => void;
+       setUploadedFiles: (files: ChatUploadedFile[]) => void;
+       save: () => void;
+       saveOnly: () => void;
+       cancel: () => void;
+       startEdit: () => void;
+}
+
+export type MessageEditContext = MessageEditState & MessageEditActions;
+
+const MESSAGE_EDIT_KEY = Symbol.for('chat-message-edit');
+
+/**
+ * Sets the message edit context. Call this in the parent component (ChatMessage.svelte).
+ */
+export function setMessageEditContext(ctx: MessageEditContext): MessageEditContext {
+       return setContext(MESSAGE_EDIT_KEY, ctx);
+}
+
+/**
+ * Gets the message edit context. Call this in child components.
+ */
+export function getMessageEditContext(): MessageEditContext {
+       return getContext(MESSAGE_EDIT_KEY);
+}
index 2b9eb7bc2e96b87e625daa04a34b99afcdd0e130..0b6f357d9a1ae892e5db0f35b5da0e5e3e115c0b 100644 (file)
@@ -1,4 +1,51 @@
 export enum ChatMessageStatsView {
        GENERATION = 'generation',
-       READING = 'reading'
+       READING = 'reading',
+       TOOLS = 'tools',
+       SUMMARY = 'summary'
+}
+
+/**
+ * Reasoning format options for API requests.
+ */
+export enum ReasoningFormat {
+       NONE = 'none',
+       AUTO = 'auto'
+}
+
+/**
+ * Message roles for chat messages.
+ */
+export enum MessageRole {
+       USER = 'user',
+       ASSISTANT = 'assistant',
+       SYSTEM = 'system',
+       TOOL = 'tool'
+}
+
+/**
+ * Message types for different content kinds.
+ */
+export enum MessageType {
+       ROOT = 'root',
+       TEXT = 'text',
+       THINK = 'think',
+       SYSTEM = 'system'
+}
+
+/**
+ * Content part types for API chat message content.
+ */
+export enum ContentPartType {
+       TEXT = 'text',
+       IMAGE_URL = 'image_url',
+       INPUT_AUDIO = 'input_audio'
+}
+
+/**
+ * Error dialog types for displaying server/timeout errors.
+ */
+export enum ErrorDialogType {
+       TIMEOUT = 'timeout',
+       SERVER = 'server'
 }
diff --git a/tools/server/webui/src/lib/enums/keyboard.ts b/tools/server/webui/src/lib/enums/keyboard.ts
new file mode 100644 (file)
index 0000000..b8f6d5f
--- /dev/null
@@ -0,0 +1,15 @@
+/**
+ * Keyboard key names for event handling
+ */
+export enum KeyboardKey {
+       ENTER = 'Enter',
+       ESCAPE = 'Escape',
+       ARROW_UP = 'ArrowUp',
+       ARROW_DOWN = 'ArrowDown',
+       TAB = 'Tab',
+       D_LOWER = 'd',
+       D_UPPER = 'D',
+       E_UPPER = 'E',
+       K_LOWER = 'k',
+       O_UPPER = 'O'
+}
diff --git a/tools/server/webui/src/lib/enums/settings.ts b/tools/server/webui/src/lib/enums/settings.ts
new file mode 100644 (file)
index 0000000..f17f219
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * Parameter source - indicates whether a parameter uses default or custom value
+ */
+export enum ParameterSource {
+       DEFAULT = 'default',
+       CUSTOM = 'custom'
+}
+
+/**
+ * Syncable parameter type - data types for parameters that can be synced with server
+ */
+export enum SyncableParameterType {
+       NUMBER = 'number',
+       STRING = 'string',
+       BOOLEAN = 'boolean'
+}
+
+/**
+ * Settings field type - defines the input type for settings fields
+ */
+export enum SettingsFieldType {
+       INPUT = 'input',
+       TEXTAREA = 'textarea',
+       CHECKBOX = 'checkbox',
+       SELECT = 'select'
+}
diff --git a/tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts b/tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts
new file mode 100644 (file)
index 0000000..bbaa5d1
--- /dev/null
@@ -0,0 +1,165 @@
+import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants/auto-scroll';
+
+export interface AutoScrollOptions {
+       /** Whether auto-scroll is disabled globally (e.g., from settings) */
+       disabled?: boolean;
+}
+
+/**
+ * Creates an auto-scroll controller for a scrollable container.
+ *
+ * Features:
+ * - Auto-scrolls to bottom during streaming/loading
+ * - Stops auto-scroll when user manually scrolls up
+ * - Resumes auto-scroll when user scrolls back to bottom
+ */
+export class AutoScrollController {
+       private _autoScrollEnabled = $state(true);
+       private _userScrolledUp = $state(false);
+       private _lastScrollTop = $state(0);
+       private _scrollInterval: ReturnType<typeof setInterval> | undefined;
+       private _scrollTimeout: ReturnType<typeof setTimeout> | undefined;
+       private _container: HTMLElement | undefined;
+       private _disabled: boolean;
+
+       constructor(options: AutoScrollOptions = {}) {
+               this._disabled = options.disabled ?? false;
+       }
+
+       get autoScrollEnabled(): boolean {
+               return this._autoScrollEnabled;
+       }
+
+       get userScrolledUp(): boolean {
+               return this._userScrolledUp;
+       }
+
+       /**
+        * Binds the controller to a scrollable container element.
+        */
+       setContainer(container: HTMLElement | undefined): void {
+               this._container = container;
+       }
+
+       /**
+        * Updates the disabled state.
+        */
+       setDisabled(disabled: boolean): void {
+               this._disabled = disabled;
+               if (disabled) {
+                       this._autoScrollEnabled = false;
+                       this.stopInterval();
+               }
+       }
+
+       /**
+        * Handles scroll events to detect user scroll direction and toggle auto-scroll.
+        */
+       handleScroll(): void {
+               if (this._disabled || !this._container) return;
+
+               const { scrollTop, scrollHeight, clientHeight } = this._container;
+               const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
+               const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
+
+               if (scrollTop < this._lastScrollTop && !isAtBottom) {
+                       this._userScrolledUp = true;
+                       this._autoScrollEnabled = false;
+               } else if (isAtBottom && this._userScrolledUp) {
+                       this._userScrolledUp = false;
+                       this._autoScrollEnabled = true;
+               }
+
+               if (this._scrollTimeout) {
+                       clearTimeout(this._scrollTimeout);
+               }
+
+               this._scrollTimeout = setTimeout(() => {
+                       if (isAtBottom) {
+                               this._userScrolledUp = false;
+                               this._autoScrollEnabled = true;
+                       }
+               }, AUTO_SCROLL_INTERVAL);
+
+               this._lastScrollTop = scrollTop;
+       }
+
+       /**
+        * Scrolls the container to the bottom.
+        */
+       scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
+               if (this._disabled || !this._container) return;
+
+               this._container.scrollTo({
+                       top: this._container.scrollHeight,
+                       behavior
+               });
+       }
+
+       /**
+        * Enables auto-scroll (e.g., when user sends a message).
+        */
+       enable(): void {
+               if (this._disabled) return;
+               this._userScrolledUp = false;
+               this._autoScrollEnabled = true;
+       }
+
+       /**
+        * Starts the auto-scroll interval for continuous scrolling during streaming.
+        */
+       startInterval(): void {
+               if (this._disabled || this._scrollInterval) return;
+
+               this._scrollInterval = setInterval(() => {
+                       this.scrollToBottom();
+               }, AUTO_SCROLL_INTERVAL);
+       }
+
+       /**
+        * Stops the auto-scroll interval.
+        */
+       stopInterval(): void {
+               if (this._scrollInterval) {
+                       clearInterval(this._scrollInterval);
+                       this._scrollInterval = undefined;
+               }
+       }
+
+       /**
+        * Updates the auto-scroll interval based on streaming state.
+        * Call this in a $effect to automatically manage the interval.
+        */
+       updateInterval(isStreaming: boolean): void {
+               if (this._disabled) {
+                       this.stopInterval();
+                       return;
+               }
+
+               if (isStreaming && this._autoScrollEnabled) {
+                       if (!this._scrollInterval) {
+                               this.startInterval();
+                       }
+               } else {
+                       this.stopInterval();
+               }
+       }
+
+       /**
+        * Cleans up resources. Call this in onDestroy or when the component unmounts.
+        */
+       destroy(): void {
+               this.stopInterval();
+               if (this._scrollTimeout) {
+                       clearTimeout(this._scrollTimeout);
+                       this._scrollTimeout = undefined;
+               }
+       }
+}
+
+/**
+ * Creates a new AutoScrollController instance.
+ */
+export function createAutoScrollController(options: AutoScrollOptions = {}): AutoScrollController {
+       return new AutoScrollController(options);
+}
index c06cf28864ae453597712d5e066fd8deb8af076d..068440cdc03b5f8347ebd654d515c3f0f05b8fd5 100644 (file)
@@ -1,7 +1,9 @@
 import { activeProcessingState } from '$lib/stores/chat.svelte';
 import { config } from '$lib/stores/settings.svelte';
+import { STATS_UNITS } from '$lib/constants/processing-info';
+import type { ApiProcessingState } from '$lib/types';
 
-export interface LiveProcessingStats {
+interface LiveProcessingStats {
        tokensProcessed: number;
        totalTokens: number;
        timeMs: number;
@@ -9,7 +11,7 @@ export interface LiveProcessingStats {
        etaSecs?: number;
 }
 
-export interface LiveGenerationStats {
+interface LiveGenerationStats {
        tokensGenerated: number;
        timeMs: number;
        tokensPerSecond: number;
@@ -18,6 +20,7 @@ export interface LiveGenerationStats {
 export interface UseProcessingStateReturn {
        readonly processingState: ApiProcessingState | null;
        getProcessingDetails(): string[];
+       getTechnicalDetails(): string[];
        getProcessingMessage(): string;
        getPromptProgressText(): string | null;
        getLiveProcessingStats(): LiveProcessingStats | null;
@@ -138,8 +141,81 @@ export function useProcessingState(): UseProcessingStateReturn {
 
                const details: string[] = [];
 
+               // Show prompt processing progress with ETA during preparation phase
+               if (stateToUse.promptProgress) {
+                       const { processed, total, time_ms, cache } = stateToUse.promptProgress;
+                       const actualProcessed = processed - cache;
+                       const actualTotal = total - cache;
+
+                       if (actualProcessed < actualTotal && actualProcessed > 0) {
+                               const percent = Math.round((actualProcessed / actualTotal) * 100);
+                               const eta = getETASecs(actualProcessed, actualTotal, time_ms);
+
+                               if (eta !== undefined) {
+                                       const etaSecs = Math.ceil(eta);
+                                       details.push(`Processing ${percent}% (ETA: ${etaSecs}s)`);
+                               } else {
+                                       details.push(`Processing ${percent}%`);
+                               }
+                       }
+               }
+
+               // Always show context info when we have valid data
+               if (
+                       typeof stateToUse.contextTotal === 'number' &&
+                       stateToUse.contextUsed >= 0 &&
+                       stateToUse.contextTotal > 0
+               ) {
+                       const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100);
+
+                       details.push(
+                               `Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)`
+                       );
+               }
+
+               if (stateToUse.outputTokensUsed > 0) {
+                       // Handle infinite max_tokens (-1) case
+                       if (stateToUse.outputTokensMax <= 0) {
+                               details.push(`Output: ${stateToUse.outputTokensUsed}/∞`);
+                       } else {
+                               const outputPercent = Math.round(
+                                       (stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100
+                               );
+
+                               details.push(
+                                       `Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)`
+                               );
+                       }
+               }
+
+               if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
+                       details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`);
+               }
+
+               if (stateToUse.speculative) {
+                       details.push('Speculative decoding enabled');
+               }
+
+               return details;
+       }
+
+       /**
+        * Returns technical details without the progress message (for bottom bar)
+        */
+       function getTechnicalDetails(): string[] {
+               const stateToUse = processingState || lastKnownState;
+               if (!stateToUse) {
+                       return [];
+               }
+
+               const details: string[] = [];
+
                // Always show context info when we have valid data
-               if (stateToUse.contextUsed >= 0 && stateToUse.contextTotal > 0) {
+               if (
+                       typeof stateToUse.contextTotal === 'number' &&
+                       stateToUse.contextUsed >= 0 &&
+                       stateToUse.contextTotal > 0
+               ) {
                        const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100);
 
                        details.push(
@@ -163,7 +239,7 @@ export function useProcessingState(): UseProcessingStateReturn {
                }
 
                if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
-                       details.push(`${stateToUse.tokensPerSecond.toFixed(1)} tokens/sec`);
+                       details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`);
                }
 
                if (stateToUse.speculative) {
@@ -251,6 +327,7 @@ export function useProcessingState(): UseProcessingStateReturn {
                        return processingState;
                },
                getProcessingDetails,
+               getTechnicalDetails,
                getProcessingMessage,
                getPromptProgressText,
                getLiveProcessingStats,
index 6f0e03e2116fd13a88eec45f044d5765140bada2..168de974037d68e3e7fbdb6bf2ba1c0d403d2c23 100644 (file)
 import type { Plugin } from 'unified';
 import type { Root, Element, ElementContent } from 'hast';
 import { visit } from 'unist-util-visit';
+import {
+       CODE_BLOCK_SCROLL_CONTAINER_CLASS,
+       CODE_BLOCK_WRAPPER_CLASS,
+       CODE_BLOCK_HEADER_CLASS,
+       CODE_BLOCK_ACTIONS_CLASS,
+       CODE_LANGUAGE_CLASS,
+       COPY_CODE_BTN_CLASS,
+       PREVIEW_CODE_BTN_CLASS,
+       RELATIVE_CLASS
+} from '$lib/constants/code-blocks';
 
 declare global {
        interface Window {
@@ -42,7 +52,7 @@ function createCopyButton(codeId: string): Element {
                type: 'element',
                tagName: 'button',
                properties: {
-                       className: ['copy-code-btn'],
+                       className: [COPY_CODE_BTN_CLASS],
                        'data-code-id': codeId,
                        title: 'Copy code',
                        type: 'button'
@@ -56,7 +66,7 @@ function createPreviewButton(codeId: string): Element {
                type: 'element',
                tagName: 'button',
                properties: {
-                       className: ['preview-code-btn'],
+                       className: [PREVIEW_CODE_BTN_CLASS],
                        'data-code-id': codeId,
                        title: 'Preview code',
                        type: 'button'
@@ -75,30 +85,39 @@ function createHeader(language: string, codeId: string): Element {
        return {
                type: 'element',
                tagName: 'div',
-               properties: { className: ['code-block-header'] },
+               properties: { className: [CODE_BLOCK_HEADER_CLASS] },
                children: [
                        {
                                type: 'element',
                                tagName: 'span',
-                               properties: { className: ['code-language'] },
+                               properties: { className: [CODE_LANGUAGE_CLASS] },
                                children: [{ type: 'text', value: language }]
                        },
                        {
                                type: 'element',
                                tagName: 'div',
-                               properties: { className: ['code-block-actions'] },
+                               properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
                                children: actions
                        }
                ]
        };
 }
 
+function createScrollContainer(preElement: Element): Element {
+       return {
+               type: 'element',
+               tagName: 'div',
+               properties: { className: [CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
+               children: [preElement]
+       };
+}
+
 function createWrapper(header: Element, preElement: Element): Element {
        return {
                type: 'element',
                tagName: 'div',
-               properties: { className: ['code-block-wrapper'] },
-               children: [header, preElement]
+               properties: { className: [CODE_BLOCK_WRAPPER_CLASS, RELATIVE_CLASS] },
+               children: [header, createScrollContainer(preElement)]
        };
 }
 
diff --git a/tools/server/webui/src/lib/services/database.service.ts b/tools/server/webui/src/lib/services/database.service.ts
new file mode 100644 (file)
index 0000000..0d5a9c1
--- /dev/null
@@ -0,0 +1,368 @@
+import Dexie, { type EntityTable } from 'dexie';
+import { findDescendantMessages } from '$lib/utils';
+
+class LlamacppDatabase extends Dexie {
+       conversations!: EntityTable<DatabaseConversation, string>;
+       messages!: EntityTable<DatabaseMessage, string>;
+
+       constructor() {
+               super('LlamacppWebui');
+
+               this.version(1).stores({
+                       conversations: 'id, lastModified, currNode, name',
+                       messages: 'id, convId, type, role, timestamp, parent, children'
+               });
+       }
+}
+
+const db = new LlamacppDatabase();
+import { v4 as uuid } from 'uuid';
+import { MessageRole } from '$lib/enums/chat';
+
+export class DatabaseService {
+       /**
+        *
+        *
+        * Conversations
+        *
+        *
+        */
+
+       /**
+        * Creates a new conversation.
+        *
+        * @param name - Name of the conversation
+        * @returns The created conversation
+        */
+       static async createConversation(name: string): Promise<DatabaseConversation> {
+               const conversation: DatabaseConversation = {
+                       id: uuid(),
+                       name,
+                       lastModified: Date.now(),
+                       currNode: ''
+               };
+
+               await db.conversations.add(conversation);
+               return conversation;
+       }
+
+       /**
+        *
+        *
+        * Messages
+        *
+        *
+        */
+
+       /**
+        * Creates a new message branch by adding a message and updating parent/child relationships.
+        * Also updates the conversation's currNode to point to the new message.
+        *
+        * @param message - Message to add (without id)
+        * @param parentId - Parent message ID to attach to
+        * @returns The created message
+        */
+       static async createMessageBranch(
+               message: Omit<DatabaseMessage, 'id'>,
+               parentId: string | null
+       ): Promise<DatabaseMessage> {
+               return await db.transaction('rw', [db.conversations, db.messages], async () => {
+                       // Handle null parent (root message case)
+                       if (parentId !== null) {
+                               const parentMessage = await db.messages.get(parentId);
+                               if (!parentMessage) {
+                                       throw new Error(`Parent message ${parentId} not found`);
+                               }
+                       }
+
+                       const newMessage: DatabaseMessage = {
+                               ...message,
+                               id: uuid(),
+                               parent: parentId,
+                               toolCalls: message.toolCalls ?? '',
+                               children: []
+                       };
+
+                       await db.messages.add(newMessage);
+
+                       // Update parent's children array if parent exists
+                       if (parentId !== null) {
+                               const parentMessage = await db.messages.get(parentId);
+                               if (parentMessage) {
+                                       await db.messages.update(parentId, {
+                                               children: [...parentMessage.children, newMessage.id]
+                                       });
+                               }
+                       }
+
+                       await this.updateConversation(message.convId, {
+                               currNode: newMessage.id
+                       });
+
+                       return newMessage;
+               });
+       }
+
+       /**
+        * Creates a root message for a new conversation.
+        * Root messages are not displayed but serve as the tree root for branching.
+        *
+        * @param convId - Conversation ID
+        * @returns The created root message
+        */
+       static async createRootMessage(convId: string): Promise<string> {
+               const rootMessage: DatabaseMessage = {
+                       id: uuid(),
+                       convId,
+                       type: 'root',
+                       timestamp: Date.now(),
+                       role: MessageRole.SYSTEM,
+                       content: '',
+                       parent: null,
+                       toolCalls: '',
+                       children: []
+               };
+
+               await db.messages.add(rootMessage);
+               return rootMessage.id;
+       }
+
+       /**
+        * Creates a system prompt message for a conversation.
+        *
+        * @param convId - Conversation ID
+        * @param systemPrompt - The system prompt content (must be non-empty)
+        * @param parentId - Parent message ID (typically the root message)
+        * @returns The created system message
+        * @throws Error if systemPrompt is empty
+        */
+       static async createSystemMessage(
+               convId: string,
+               systemPrompt: string,
+               parentId: string
+       ): Promise<DatabaseMessage> {
+               const trimmedPrompt = systemPrompt.trim();
+               if (!trimmedPrompt) {
+                       throw new Error('Cannot create system message with empty content');
+               }
+
+               const systemMessage: DatabaseMessage = {
+                       id: uuid(),
+                       convId,
+                       type: MessageRole.SYSTEM,
+                       timestamp: Date.now(),
+                       role: MessageRole.SYSTEM,
+                       content: trimmedPrompt,
+                       parent: parentId,
+                       children: []
+               };
+
+               await db.messages.add(systemMessage);
+
+               const parentMessage = await db.messages.get(parentId);
+               if (parentMessage) {
+                       await db.messages.update(parentId, {
+                               children: [...parentMessage.children, systemMessage.id]
+                       });
+               }
+
+               return systemMessage;
+       }
+
+       /**
+        * Deletes a conversation and all its messages.
+        *
+        * @param id - Conversation ID
+        */
+       static async deleteConversation(id: string): Promise<void> {
+               await db.transaction('rw', [db.conversations, db.messages], async () => {
+                       await db.conversations.delete(id);
+                       await db.messages.where('convId').equals(id).delete();
+               });
+       }
+
+       /**
+        * Deletes a message and removes it from its parent's children array.
+        *
+        * @param messageId - ID of the message to delete
+        */
+       static async deleteMessage(messageId: string): Promise<void> {
+               await db.transaction('rw', db.messages, async () => {
+                       const message = await db.messages.get(messageId);
+                       if (!message) return;
+
+                       // Remove this message from its parent's children array
+                       if (message.parent) {
+                               const parent = await db.messages.get(message.parent);
+                               if (parent) {
+                                       parent.children = parent.children.filter((childId: string) => childId !== messageId);
+                                       await db.messages.put(parent);
+                               }
+                       }
+
+                       // Delete the message
+                       await db.messages.delete(messageId);
+               });
+       }
+
+       /**
+        * Deletes a message and all its descendant messages (cascading deletion).
+        * This removes the entire branch starting from the specified message.
+        *
+        * @param conversationId - ID of the conversation containing the message
+        * @param messageId - ID of the root message to delete (along with all descendants)
+        * @returns Array of all deleted message IDs
+        */
+       static async deleteMessageCascading(
+               conversationId: string,
+               messageId: string
+       ): Promise<string[]> {
+               return await db.transaction('rw', db.messages, async () => {
+                       // Get all messages in the conversation to find descendants
+                       const allMessages = await db.messages.where('convId').equals(conversationId).toArray();
+
+                       // Find all descendant messages
+                       const descendants = findDescendantMessages(allMessages, messageId);
+                       const allToDelete = [messageId, ...descendants];
+
+                       // Get the message to delete for parent cleanup
+                       const message = await db.messages.get(messageId);
+                       if (message && message.parent) {
+                               const parent = await db.messages.get(message.parent);
+                               if (parent) {
+                                       parent.children = parent.children.filter((childId: string) => childId !== messageId);
+                                       await db.messages.put(parent);
+                               }
+                       }
+
+                       // Delete all messages in the branch
+                       await db.messages.bulkDelete(allToDelete);
+
+                       return allToDelete;
+               });
+       }
+
+       /**
+        * Gets all conversations, sorted by last modified time (newest first).
+        *
+        * @returns Array of conversations
+        */
+       static async getAllConversations(): Promise<DatabaseConversation[]> {
+               return await db.conversations.orderBy('lastModified').reverse().toArray();
+       }
+
+       /**
+        * Gets a conversation by ID.
+        *
+        * @param id - Conversation ID
+        * @returns The conversation if found, otherwise undefined
+        */
+       static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
+               return await db.conversations.get(id);
+       }
+
+       /**
+        * Gets all messages in a conversation, sorted by timestamp (oldest first).
+        *
+        * @param convId - Conversation ID
+        * @returns Array of messages in the conversation
+        */
+       static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
+               return await db.messages.where('convId').equals(convId).sortBy('timestamp');
+       }
+
+       /**
+        * Updates a conversation.
+        *
+        * @param id - Conversation ID
+        * @param updates - Partial updates to apply
+        * @returns Promise that resolves when the conversation is updated
+        */
+       static async updateConversation(
+               id: string,
+               updates: Partial<Omit<DatabaseConversation, 'id'>>
+       ): Promise<void> {
+               await db.conversations.update(id, {
+                       ...updates,
+                       lastModified: Date.now()
+               });
+       }
+
+       /**
+        *
+        *
+        * Navigation
+        *
+        *
+        */
+
+       /**
+        * Updates the conversation's current node (active branch).
+        * This determines which conversation path is currently being viewed.
+        *
+        * @param convId - Conversation ID
+        * @param nodeId - Message ID to set as current node
+        */
+       static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
+               await this.updateConversation(convId, {
+                       currNode: nodeId
+               });
+       }
+
+       /**
+        * Updates a message.
+        *
+        * @param id - Message ID
+        * @param updates - Partial updates to apply
+        * @returns Promise that resolves when the message is updated
+        */
+       static async updateMessage(
+               id: string,
+               updates: Partial<Omit<DatabaseMessage, 'id'>>
+       ): Promise<void> {
+               await db.messages.update(id, updates);
+       }
+
+       /**
+        *
+        *
+        * Import
+        *
+        *
+        */
+
+       /**
+        * Imports multiple conversations and their messages.
+        * Skips conversations that already exist.
+        *
+        * @param data - Array of { conv, messages } objects
+        */
+       static async importConversations(
+               data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
+       ): Promise<{ imported: number; skipped: number }> {
+               let importedCount = 0;
+               let skippedCount = 0;
+
+               return await db.transaction('rw', [db.conversations, db.messages], async () => {
+                       for (const item of data) {
+                               const { conv, messages } = item;
+
+                               const existing = await db.conversations.get(conv.id);
+                               if (existing) {
+                                       console.warn(`Conversation "${conv.name}" already exists, skipping...`);
+                                       skippedCount++;
+                                       continue;
+                               }
+
+                               await db.conversations.add(conv);
+                               for (const msg of messages) {
+                                       await db.messages.put(msg);
+                               }
+
+                               importedCount++;
+                       }
+
+                       return { imported: importedCount, skipped: skippedCount };
+               });
+       }
+}
diff --git a/tools/server/webui/src/lib/services/models.service.ts b/tools/server/webui/src/lib/services/models.service.ts
new file mode 100644 (file)
index 0000000..7357c3f
--- /dev/null
@@ -0,0 +1,99 @@
+import { ServerModelStatus } from '$lib/enums';
+import { apiFetch, apiPost } from '$lib/utils/api-fetch';
+
+export class ModelsService {
+       /**
+        *
+        *
+        * Listing
+        *
+        *
+        */
+
+       /**
+        * Fetch list of models from OpenAI-compatible endpoint.
+        * Works in both MODEL and ROUTER modes.
+        *
+        * @returns List of available models with basic metadata
+        */
+       static async list(): Promise<ApiModelListResponse> {
+               return apiFetch<ApiModelListResponse>('/v1/models');
+       }
+
+       /**
+        * Fetch list of all models with detailed metadata (ROUTER mode).
+        * Returns models with load status, paths, and other metadata
+        * beyond what the OpenAI-compatible endpoint provides.
+        *
+        * @returns List of models with detailed status and configuration info
+        */
+       static async listRouter(): Promise<ApiRouterModelsListResponse> {
+               return apiFetch<ApiRouterModelsListResponse>('/v1/models');
+       }
+
+       /**
+        *
+        *
+        * Load/Unload
+        *
+        *
+        */
+
+       /**
+        * Load a model (ROUTER mode only).
+        * Sends POST request to `/models/load`. Note: the endpoint returns success
+        * before loading completes â€” use polling to await actual load status.
+        *
+        * @param modelId - Model identifier to load
+        * @param extraArgs - Optional additional arguments to pass to the model instance
+        * @returns Load response from the server
+        */
+       static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
+               const payload: { model: string; extra_args?: string[] } = { model: modelId };
+               if (extraArgs && extraArgs.length > 0) {
+                       payload.extra_args = extraArgs;
+               }
+
+               return apiPost<ApiRouterModelsLoadResponse>('/models/load', payload);
+       }
+
+       /**
+        * Unload a model (ROUTER mode only).
+        * Sends POST request to `/models/unload`. Note: the endpoint returns success
+        * before unloading completes â€” use polling to await actual unload status.
+        *
+        * @param modelId - Model identifier to unload
+        * @returns Unload response from the server
+        */
+       static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
+               return apiPost<ApiRouterModelsUnloadResponse>('/models/unload', { model: modelId });
+       }
+
+       /**
+        *
+        *
+        * Status
+        *
+        *
+        */
+
+       /**
+        * Check if a model is loaded based on its metadata.
+        *
+        * @param model - Model data entry from the API response
+        * @returns True if the model status is LOADED
+        */
+       static isModelLoaded(model: ApiModelDataEntry): boolean {
+               return model.status.value === ServerModelStatus.LOADED;
+       }
+
+       /**
+        * Check if a model is currently loading.
+        *
+        * @param model - Model data entry from the API response
+        * @returns True if the model status is LOADING
+        */
+       static isModelLoading(model: ApiModelDataEntry): boolean {
+               return model.status.value === ServerModelStatus.LOADING;
+       }
+}
diff --git a/tools/server/webui/src/lib/services/parameter-sync.service.spec.ts b/tools/server/webui/src/lib/services/parameter-sync.service.spec.ts
new file mode 100644 (file)
index 0000000..46cce5e
--- /dev/null
@@ -0,0 +1,148 @@
+import { describe, it, expect } from 'vitest';
+import { ParameterSyncService } from './parameter-sync.service';
+
+describe('ParameterSyncService', () => {
+       describe('roundFloatingPoint', () => {
+               it('should fix JavaScript floating-point precision issues', () => {
+                       // Test the specific values from the screenshot
+                       const mockServerParams = {
+                               top_p: 0.949999988079071,
+                               min_p: 0.009999999776482582,
+                               temperature: 0.800000011920929,
+                               top_k: 40,
+                               samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature']
+                       };
+
+                       const result = ParameterSyncService.extractServerDefaults({
+                               ...mockServerParams,
+                               // Add other required fields to match the API type
+                               n_predict: 512,
+                               seed: -1,
+                               dynatemp_range: 0.0,
+                               dynatemp_exponent: 1.0,
+                               xtc_probability: 0.0,
+                               xtc_threshold: 0.1,
+                               typ_p: 1.0,
+                               repeat_last_n: 64,
+                               repeat_penalty: 1.0,
+                               presence_penalty: 0.0,
+                               frequency_penalty: 0.0,
+                               dry_multiplier: 0.0,
+                               dry_base: 1.75,
+                               dry_allowed_length: 2,
+                               dry_penalty_last_n: -1,
+                               mirostat: 0,
+                               mirostat_tau: 5.0,
+                               mirostat_eta: 0.1,
+                               stop: [],
+                               max_tokens: -1,
+                               n_keep: 0,
+                               n_discard: 0,
+                               ignore_eos: false,
+                               stream: true,
+                               logit_bias: [],
+                               n_probs: 0,
+                               min_keep: 0,
+                               grammar: '',
+                               grammar_lazy: false,
+                               grammar_triggers: [],
+                               preserved_tokens: [],
+                               chat_format: '',
+                               reasoning_format: '',
+                               reasoning_in_content: false,
+                               thinking_forced_open: false,
+                               'speculative.n_max': 0,
+                               'speculative.n_min': 0,
+                               'speculative.p_min': 0.0,
+                               timings_per_token: false,
+                               post_sampling_probs: false,
+                               lora: [],
+                               top_n_sigma: 0.0,
+                               dry_sequence_breakers: []
+                       } as ApiLlamaCppServerProps['default_generation_settings']['params']);
+
+                       // Check that the problematic floating-point values are rounded correctly
+                       expect(result.top_p).toBe(0.95);
+                       expect(result.min_p).toBe(0.01);
+                       expect(result.temperature).toBe(0.8);
+                       expect(result.top_k).toBe(40); // Integer should remain unchanged
+                       expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature');
+               });
+
+               it('should preserve non-numeric values', () => {
+                       const mockServerParams = {
+                               samplers: ['top_k', 'temperature'],
+                               max_tokens: -1,
+                               temperature: 0.7
+                       };
+
+                       const result = ParameterSyncService.extractServerDefaults({
+                               ...mockServerParams,
+                               // Minimal required fields
+                               n_predict: 512,
+                               seed: -1,
+                               dynatemp_range: 0.0,
+                               dynatemp_exponent: 1.0,
+                               top_k: 40,
+                               top_p: 0.95,
+                               min_p: 0.05,
+                               xtc_probability: 0.0,
+                               xtc_threshold: 0.1,
+                               typ_p: 1.0,
+                               repeat_last_n: 64,
+                               repeat_penalty: 1.0,
+                               presence_penalty: 0.0,
+                               frequency_penalty: 0.0,
+                               dry_multiplier: 0.0,
+                               dry_base: 1.75,
+                               dry_allowed_length: 2,
+                               dry_penalty_last_n: -1,
+                               mirostat: 0,
+                               mirostat_tau: 5.0,
+                               mirostat_eta: 0.1,
+                               stop: [],
+                               n_keep: 0,
+                               n_discard: 0,
+                               ignore_eos: false,
+                               stream: true,
+                               logit_bias: [],
+                               n_probs: 0,
+                               min_keep: 0,
+                               grammar: '',
+                               grammar_lazy: false,
+                               grammar_triggers: [],
+                               preserved_tokens: [],
+                               chat_format: '',
+                               reasoning_format: '',
+                               reasoning_in_content: false,
+                               thinking_forced_open: false,
+                               'speculative.n_max': 0,
+                               'speculative.n_min': 0,
+                               'speculative.p_min': 0.0,
+                               timings_per_token: false,
+                               post_sampling_probs: false,
+                               lora: [],
+                               top_n_sigma: 0.0,
+                               dry_sequence_breakers: []
+                       } as ApiLlamaCppServerProps['default_generation_settings']['params']);
+
+                       expect(result.samplers).toBe('top_k;temperature');
+                       expect(result.max_tokens).toBe(-1);
+                       expect(result.temperature).toBe(0.7);
+               });
+
+               it('should merge webui settings from props when provided', () => {
+                       const result = ParameterSyncService.extractServerDefaults(null, {
+                               pasteLongTextToFileLen: 0,
+                               pdfAsImage: true,
+                               renderUserContentAsMarkdown: false,
+                               theme: 'dark'
+                       });
+
+                       expect(result.pasteLongTextToFileLen).toBe(0);
+                       expect(result.pdfAsImage).toBe(true);
+                       expect(result.renderUserContentAsMarkdown).toBe(false);
+                       expect(result.theme).toBeUndefined();
+               });
+       });
+});
diff --git a/tools/server/webui/src/lib/services/parameter-sync.service.ts b/tools/server/webui/src/lib/services/parameter-sync.service.ts
new file mode 100644 (file)
index 0000000..6cb53d1
--- /dev/null
@@ -0,0 +1,400 @@
+import { normalizeFloatingPoint } from '$lib/utils';
+import { SyncableParameterType, ParameterSource } from '$lib/enums/settings';
+
+type ParameterValue = string | number | boolean;
+type ParameterRecord = Record<string, ParameterValue>;
+
+interface ParameterInfo {
+       value: string | number | boolean;
+       source: ParameterSource;
+       serverDefault?: string | number | boolean;
+       userOverride?: string | number | boolean;
+}
+
+interface SyncableParameter {
+       key: string;
+       serverKey: string;
+       type: SyncableParameterType;
+       canSync: boolean;
+}
+
+/**
+ * Mapping of webui setting keys to server parameter keys.
+ * Only parameters listed here can be synced from the server `/props` endpoint.
+ * Each entry defines the webui key, corresponding server key, value type,
+ * and whether sync is enabled.
+ */
+export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
+       {
+               key: 'temperature',
+               serverKey: 'temperature',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       { key: 'top_k', serverKey: 'top_k', type: SyncableParameterType.NUMBER, canSync: true },
+       { key: 'top_p', serverKey: 'top_p', type: SyncableParameterType.NUMBER, canSync: true },
+       { key: 'min_p', serverKey: 'min_p', type: SyncableParameterType.NUMBER, canSync: true },
+       {
+               key: 'dynatemp_range',
+               serverKey: 'dynatemp_range',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       {
+               key: 'dynatemp_exponent',
+               serverKey: 'dynatemp_exponent',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       {
+               key: 'xtc_probability',
+               serverKey: 'xtc_probability',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       {
+               key: 'xtc_threshold',
+               serverKey: 'xtc_threshold',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       { key: 'typ_p', serverKey: 'typ_p', type: SyncableParameterType.NUMBER, canSync: true },
+       {
+               key: 'repeat_last_n',
+               serverKey: 'repeat_last_n',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       {
+               key: 'repeat_penalty',
+               serverKey: 'repeat_penalty',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       {
+               key: 'presence_penalty',
+               serverKey: 'presence_penalty',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       {
+               key: 'frequency_penalty',
+               serverKey: 'frequency_penalty',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       {
+               key: 'dry_multiplier',
+               serverKey: 'dry_multiplier',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       { key: 'dry_base', serverKey: 'dry_base', type: SyncableParameterType.NUMBER, canSync: true },
+       {
+               key: 'dry_allowed_length',
+               serverKey: 'dry_allowed_length',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       {
+               key: 'dry_penalty_last_n',
+               serverKey: 'dry_penalty_last_n',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       { key: 'max_tokens', serverKey: 'max_tokens', type: SyncableParameterType.NUMBER, canSync: true },
+       { key: 'samplers', serverKey: 'samplers', type: SyncableParameterType.STRING, canSync: true },
+       {
+               key: 'pasteLongTextToFileLen',
+               serverKey: 'pasteLongTextToFileLen',
+               type: SyncableParameterType.NUMBER,
+               canSync: true
+       },
+       {
+               key: 'pdfAsImage',
+               serverKey: 'pdfAsImage',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       },
+       {
+               key: 'showThoughtInProgress',
+               serverKey: 'showThoughtInProgress',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       },
+       {
+               key: 'keepStatsVisible',
+               serverKey: 'keepStatsVisible',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       },
+       {
+               key: 'showMessageStats',
+               serverKey: 'showMessageStats',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       },
+       {
+               key: 'askForTitleConfirmation',
+               serverKey: 'askForTitleConfirmation',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       },
+       {
+               key: 'disableAutoScroll',
+               serverKey: 'disableAutoScroll',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       },
+       {
+               key: 'renderUserContentAsMarkdown',
+               serverKey: 'renderUserContentAsMarkdown',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       },
+       {
+               key: 'autoMicOnEmpty',
+               serverKey: 'autoMicOnEmpty',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       },
+       {
+               key: 'pyInterpreterEnabled',
+               serverKey: 'pyInterpreterEnabled',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       },
+       {
+               key: 'enableContinueGeneration',
+               serverKey: 'enableContinueGeneration',
+               type: SyncableParameterType.BOOLEAN,
+               canSync: true
+       }
+];
+
+export class ParameterSyncService {
+       /**
+        *
+        *
+        * Extraction
+        *
+        *
+        */
+
+       /**
+        * Round floating-point numbers to avoid JavaScript precision issues.
+        * E.g., 0.1 + 0.2 = 0.30000000000000004 â†’ 0.3
+        *
+        * @param value - Parameter value to normalize
+        * @returns Precision-normalized value
+        */
+       private static roundFloatingPoint(value: ParameterValue): ParameterValue {
+               return normalizeFloatingPoint(value) as ParameterValue;
+       }
+
+       /**
+        * Extract server default parameters that can be synced from `/props` response.
+        * Handles both generation settings parameters and webui-specific settings.
+        * Converts samplers array to semicolon-delimited string for UI display.
+        *
+        * @param serverParams - Raw generation settings from server `/props` endpoint
+        * @param webuiSettings - Optional webui-specific settings from server
+        * @returns Record of extracted parameter key-value pairs with normalized precision
+        */
+       static extractServerDefaults(
+               serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
+               webuiSettings?: Record<string, string | number | boolean>
+       ): ParameterRecord {
+               const extracted: ParameterRecord = {};
+
+               if (serverParams) {
+                       for (const param of SYNCABLE_PARAMETERS) {
+                               if (param.canSync && param.serverKey in serverParams) {
+                                       const value = (serverParams as unknown as Record<string, ParameterValue>)[
+                                               param.serverKey
+                                       ];
+                                       if (value !== undefined) {
+                                               // Apply precision rounding to avoid JavaScript floating-point issues
+                                               extracted[param.key] = this.roundFloatingPoint(value);
+                                       }
+                               }
+                       }
+
+                       // Handle samplers array conversion to string
+                       if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
+                               extracted.samplers = serverParams.samplers.join(';');
+                       }
+               }
+
+               if (webuiSettings) {
+                       for (const param of SYNCABLE_PARAMETERS) {
+                               if (param.canSync && param.serverKey in webuiSettings) {
+                                       const value = webuiSettings[param.serverKey];
+                                       if (value !== undefined) {
+                                               extracted[param.key] = this.roundFloatingPoint(value);
+                                       }
+                               }
+                       }
+               }
+
+               return extracted;
+       }
+
+       /**
+        *
+        *
+        * Merging
+        *
+        *
+        */
+
+       /**
+        * Merge server defaults with current user settings.
+        * User overrides always take priority â€” only parameters not in `userOverrides`
+        * set will be updated from server defaults.
+        *
+        * @param currentSettings - Current parameter values in the settings store
+        * @param serverDefaults - Default values extracted from server props
+        * @param userOverrides - Set of parameter keys explicitly overridden by the user
+        * @returns Merged parameter record with user overrides preserved
+        */
+       static mergeWithServerDefaults(
+               currentSettings: ParameterRecord,
+               serverDefaults: ParameterRecord,
+               userOverrides: Set<string> = new Set()
+       ): ParameterRecord {
+               const merged = { ...currentSettings };
+
+               for (const [key, serverValue] of Object.entries(serverDefaults)) {
+                       // Only update if user hasn't explicitly overridden this parameter
+                       if (!userOverrides.has(key)) {
+                               merged[key] = this.roundFloatingPoint(serverValue);
+                       }
+               }
+
+               return merged;
+       }
+
+       /**
+        *
+        *
+        * Info
+        *
+        *
+        */
+
+       /**
+        * Get parameter information including source and values.
+        * Used by ChatSettingsParameterSourceIndicator to display the correct badge
+        * (Custom vs Default) for each parameter in the settings UI.
+        *
+        * @param key - The parameter key to get info for
+        * @param currentValue - The current value of the parameter
+        * @param propsDefaults - Server default values from `/props`
+        * @param userOverrides - Set of parameter keys explicitly overridden by the user
+        * @returns Parameter info with source, server default, and user override values
+        */
+       static getParameterInfo(
+               key: string,
+               currentValue: ParameterValue,
+               propsDefaults: ParameterRecord,
+               userOverrides: Set<string>
+       ): ParameterInfo {
+               const hasPropsDefault = propsDefaults[key] !== undefined;
+               const isUserOverride = userOverrides.has(key);
+
+               // Simple logic: either using default (from props) or custom (user override)
+               const source = isUserOverride ? ParameterSource.CUSTOM : ParameterSource.DEFAULT;
+
+               return {
+                       value: currentValue,
+                       source,
+                       serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
+                       userOverride: isUserOverride ? currentValue : undefined
+               };
+       }
+
+       /**
+        * Check if a parameter can be synced from server.
+        *
+        * @param key - The parameter key to check
+        * @returns True if the parameter is in the syncable parameters list
+        */
+       static canSyncParameter(key: string): boolean {
+               return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
+       }
+
+       /**
+        * Get all syncable parameter keys.
+        *
+        * @returns Array of parameter keys that can be synced from server
+        */
+       static getSyncableParameterKeys(): string[] {
+               return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
+       }
+
+       /**
+        * Validate a server parameter value against its expected type.
+        *
+        * @param key - The parameter key to validate
+        * @param value - The value to validate
+        * @returns True if value matches the expected type for this parameter
+        */
+       static validateServerParameter(key: string, value: ParameterValue): boolean {
+               const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
+               if (!param) return false;
+
+               switch (param.type) {
+                       case SyncableParameterType.NUMBER:
+                               return typeof value === 'number' && !isNaN(value);
+                       case SyncableParameterType.STRING:
+                               return typeof value === 'string';
+                       case SyncableParameterType.BOOLEAN:
+                               return typeof value === 'boolean';
+                       default:
+                               return false;
+               }
+       }
+
+       /**
+        *
+        *
+        * Diff
+        *
+        *
+        */
+
+       /**
+        * Create a diff between current settings and server defaults.
+        * Shows which parameters differ from server values, useful for debugging
+        * and for the "Reset to defaults" functionality.
+        *
+        * @param currentSettings - Current parameter values in the settings store
+        * @param serverDefaults - Default values extracted from server props
+        * @returns Record of parameter diffs with current value, server value, and whether they differ
+        */
+       static createParameterDiff(
+               currentSettings: ParameterRecord,
+               serverDefaults: ParameterRecord
+       ): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
+               const diff: Record<
+                       string,
+                       { current: ParameterValue; server: ParameterValue; differs: boolean }
+               > = {};
+
+               for (const key of this.getSyncableParameterKeys()) {
+                       const currentValue = currentSettings[key];
+                       const serverValue = serverDefaults[key];
+
+                       if (serverValue !== undefined) {
+                               diff[key] = {
+                                       current: currentValue,
+                                       server: serverValue,
+                                       differs: currentValue !== serverValue
+                               };
+                       }
+               }
+
+               return diff;
+       }
+}
diff --git a/tools/server/webui/src/lib/services/props.service.ts b/tools/server/webui/src/lib/services/props.service.ts
new file mode 100644 (file)
index 0000000..7373b7e
--- /dev/null
@@ -0,0 +1,47 @@
+import { apiFetchWithParams } from '$lib/utils/api-fetch';
+
+export class PropsService {
+       /**
+        *
+        *
+        * Fetching
+        *
+        *
+        */
+
+       /**
+        * Fetches global server properties from the `/props` endpoint.
+        * In MODEL mode, returns modalities for the single loaded model.
+        * In ROUTER mode, returns server-wide settings without model-specific modalities.
+        *
+        * @param autoload - If false, prevents automatic model loading (default: false)
+        * @returns Server properties including default generation settings and capabilities
+        * @throws {Error} If the request fails or returns invalid data
+        */
+       static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
+               const params: Record<string, string> = {};
+               if (!autoload) {
+                       params.autoload = 'false';
+               }
+
+               return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, { authOnly: true });
+       }
+
+       /**
+        * Fetches server properties for a specific model (ROUTER mode only).
+        * Required in ROUTER mode because global `/props` does not include per-model modalities.
+        *
+        * @param modelId - The model ID to fetch properties for
+        * @param autoload - If false, prevents automatic model loading (default: false)
+        * @returns Server properties specific to the requested model
+        * @throws {Error} If the request fails, model not found, or model not loaded
+        */
+       static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
+               const params: Record<string, string> = { model: modelId };
+               if (!autoload) {
+                       params.autoload = 'false';
+               }
+
+               return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, { authOnly: true });
+       }
+}
index 714509f024b11351e7139b6b711d2087dce77684..307e3b71d92b972e03b8d0cb5586ffaed86b79f1 100644 (file)
@@ -1,8 +1,19 @@
-import type { ServerModelStatus, ServerRole } from '$lib/enums';
-import type { ChatMessagePromptProgress } from './chat';
+import type { ContentPartType, ServerModelStatus, ServerRole } from '$lib/enums';
+import type { ChatMessagePromptProgress, ChatRole } from './chat';
+
+export interface ApiChatCompletionToolFunction {
+       name: string;
+       description?: string;
+       parameters: Record<string, unknown>;
+}
+
+export interface ApiChatCompletionTool {
+       type: 'function';
+       function: ApiChatCompletionToolFunction;
+}
 
 export interface ApiChatMessageContentPart {
-       type: 'text' | 'image_url' | 'input_audio';
+       type: ContentPartType;
        text?: string;
        image_url?: {
                url: string;
@@ -34,6 +45,8 @@ export interface ApiErrorResponse {
 export interface ApiChatMessageData {
        role: ChatRole;
        content: string | ApiChatMessageContentPart[];
+       tool_calls?: ApiChatCompletionToolCall[];
+       tool_call_id?: string;
        timestamp?: number;
 }
 
@@ -188,6 +201,7 @@ export interface ApiChatCompletionRequest {
        stream?: boolean;
        model?: string;
        return_progress?: boolean;
+       tools?: ApiChatCompletionTool[];
        // Reasoning parameters
        reasoning_format?: string;
        // Generation parameters
@@ -247,6 +261,7 @@ export interface ApiChatCompletionStreamChunk {
                        model?: string;
                        tool_calls?: ApiChatCompletionToolCallDelta[];
                };
+               finish_reason?: string | null;
        }>;
        timings?: {
                prompt_n?: number;
@@ -267,8 +282,9 @@ export interface ApiChatCompletionResponse {
                        content: string;
                        reasoning_content?: string;
                        model?: string;
-                       tool_calls?: ApiChatCompletionToolCallDelta[];
+                       tool_calls?: ApiChatCompletionToolCall[];
                };
+               finish_reason?: string | null;
        }>;
 }
 
@@ -335,7 +351,7 @@ export interface ApiProcessingState {
        tokensDecoded: number;
        tokensRemaining: number;
        contextUsed: number;
-       contextTotal: number;
+       contextTotal: number | null;
        outputTokensUsed: number; // Total output tokens (thinking + regular content)
        outputTokensMax: number; // Max output tokens allowed
        temperature: number;
index ef44a2cb6d4777567a0cad7715f456915b4bd3a5..505867a1f0d23d0e170b9ab287b6e0b8f608d9a8 100644 (file)
@@ -1,8 +1,5 @@
 import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
 
-/**
- * Model modalities - vision and audio capabilities
- */
 export interface ModelModalities {
        vision: boolean;
        audio: boolean;
@@ -14,8 +11,15 @@ export interface ModelOption {
        model: string;
        description?: string;
        capabilities: string[];
-       /** Model modalities from /props endpoint */
        modalities?: ModelModalities;
        details?: ApiModelDetails['details'];
        meta?: ApiModelDataEntry['meta'];
 }
+
+/**
+ * Modality capabilities for file validation
+ */
+export interface ModalityCapabilities {
+       hasVision: boolean;
+       hasAudio: boolean;
+}
diff --git a/tools/server/webui/src/lib/utils/abort.ts b/tools/server/webui/src/lib/utils/abort.ts
new file mode 100644 (file)
index 0000000..fc4f31e
--- /dev/null
@@ -0,0 +1,151 @@
+/**
+ * Abort Signal Utilities
+ *
+ * Provides utilities for consistent AbortSignal propagation across the application.
+ * These utilities help ensure that async operations can be properly cancelled
+ * when needed (e.g., user stops generation, navigates away, etc.).
+ */
+
+/**
+ * Throws an AbortError if the signal is aborted.
+ * Use this at the start of async operations to fail fast.
+ *
+ * @param signal - Optional AbortSignal to check
+ * @throws DOMException with name 'AbortError' if signal is aborted
+ *
+ * @example
+ * ```ts
+ * async function fetchData(signal?: AbortSignal) {
+ *   throwIfAborted(signal);
+ *   // ... proceed with operation
+ * }
+ * ```
+ */
+export function throwIfAborted(signal?: AbortSignal): void {
+       if (signal?.aborted) {
+               throw new DOMException('Operation was aborted', 'AbortError');
+       }
+}
+
+/**
+ * Checks if an error is an AbortError.
+ * Use this to distinguish between user-initiated cancellation and actual errors.
+ *
+ * @param error - Error to check
+ * @returns true if the error is an AbortError
+ *
+ * @example
+ * ```ts
+ * try {
+ *   await fetchData(signal);
+ * } catch (error) {
+ *   if (isAbortError(error)) {
+ *     // User cancelled - no error dialog needed
+ *     return;
+ *   }
+ *   // Handle actual error
+ * }
+ * ```
+ */
+export function isAbortError(error: unknown): boolean {
+       if (error instanceof DOMException && error.name === 'AbortError') {
+               return true;
+       }
+       if (error instanceof Error && error.name === 'AbortError') {
+               return true;
+       }
+       return false;
+}
+
+/**
+ * Creates a new AbortController that is linked to one or more parent signals.
+ * When any parent signal aborts, the returned controller also aborts.
+ *
+ * Useful for creating child operations that should be cancelled when
+ * either the parent operation or their own timeout/condition triggers.
+ *
+ * @param signals - Parent signals to link to (undefined signals are ignored)
+ * @returns A new AbortController linked to all provided signals
+ *
+ * @example
+ * ```ts
+ * // Link to user's abort signal and add a timeout
+ * const linked = createLinkedController(userSignal, timeoutSignal);
+ * await fetch(url, { signal: linked.signal });
+ * ```
+ */
+export function createLinkedController(...signals: (AbortSignal | undefined)[]): AbortController {
+       const controller = new AbortController();
+
+       for (const signal of signals) {
+               if (!signal) continue;
+
+               // If already aborted, abort immediately
+               if (signal.aborted) {
+                       controller.abort(signal.reason);
+                       return controller;
+               }
+
+               // Link to parent signal
+               signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
+       }
+
+       return controller;
+}
+
+/**
+ * Creates an AbortSignal that times out after the specified duration.
+ *
+ * @param ms - Timeout duration in milliseconds
+ * @returns AbortSignal that will abort after the timeout
+ *
+ * @example
+ * ```ts
+ * const signal = createTimeoutSignal(5000); // 5 second timeout
+ * await fetch(url, { signal });
+ * ```
+ */
+export function createTimeoutSignal(ms: number): AbortSignal {
+       return AbortSignal.timeout(ms);
+}
+
+/**
+ * Wraps a promise to reject if the signal is aborted.
+ * Useful for making non-abortable promises respect an AbortSignal.
+ *
+ * @param promise - Promise to wrap
+ * @param signal - AbortSignal to respect
+ * @returns Promise that rejects with AbortError if signal aborts
+ *
+ * @example
+ * ```ts
+ * // Make a non-abortable operation respect abort signal
+ * const result = await withAbortSignal(
+ *   someNonAbortableOperation(),
+ *   signal
+ * );
+ * ```
+ */
+export async function withAbortSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
+       if (!signal) return promise;
+
+       throwIfAborted(signal);
+
+       return new Promise<T>((resolve, reject) => {
+               const abortHandler = () => {
+                       reject(new DOMException('Operation was aborted', 'AbortError'));
+               };
+
+               signal.addEventListener('abort', abortHandler, { once: true });
+
+               promise
+                       .then((value) => {
+                               signal.removeEventListener('abort', abortHandler);
+                               resolve(value);
+                       })
+                       .catch((error) => {
+                               signal.removeEventListener('abort', abortHandler);
+                               reject(error);
+                       });
+       });
+}
diff --git a/tools/server/webui/src/lib/utils/api-fetch.ts b/tools/server/webui/src/lib/utils/api-fetch.ts
new file mode 100644 (file)
index 0000000..8081ef2
--- /dev/null
@@ -0,0 +1,155 @@
+import { base } from '$app/paths';
+import { getJsonHeaders, getAuthHeaders } from './api-headers';
+
+/**
+ * API Fetch Utilities
+ *
+ * Provides common fetch patterns used across services:
+ * - Automatic JSON headers
+ * - Error handling with proper error messages
+ * - Base path resolution
+ */
+
+export interface ApiFetchOptions extends Omit<RequestInit, 'headers'> {
+       /**
+        * Use auth-only headers (no Content-Type).
+        * Default: false (uses JSON headers with Content-Type: application/json)
+        */
+       authOnly?: boolean;
+       /**
+        * Additional headers to merge with default headers.
+        */
+       headers?: Record<string, string>;
+}
+
+/**
+ * Fetch JSON data from an API endpoint with standard headers and error handling.
+ *
+ * @param path - API path (will be prefixed with base path)
+ * @param options - Fetch options with additional authOnly flag
+ * @returns Parsed JSON response
+ * @throws Error with formatted message on failure
+ *
+ * @example
+ * ```typescript
+ * // GET request
+ * const models = await apiFetch<ApiModelListResponse>('/v1/models');
+ *
+ * // POST request
+ * const result = await apiFetch<ApiResponse>('/models/load', {
+ *   method: 'POST',
+ *   body: JSON.stringify({ model: 'gpt-4' })
+ * });
+ * ```
+ */
+export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
+       const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
+
+       const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
+       const headers = { ...baseHeaders, ...customHeaders };
+
+       const url =
+               path.startsWith('http://') || path.startsWith('https://') ? path : `${base}${path}`;
+
+       const response = await fetch(url, {
+               ...fetchOptions,
+               headers
+       });
+
+       if (!response.ok) {
+               const errorMessage = await parseErrorMessage(response);
+               throw new Error(errorMessage);
+       }
+
+       return response.json() as Promise<T>;
+}
+
+/**
+ * Fetch with URL constructed from base URL and query parameters.
+ *
+ * @param basePath - Base API path
+ * @param params - Query parameters to append
+ * @param options - Fetch options
+ * @returns Parsed JSON response
+ *
+ * @example
+ * ```typescript
+ * const props = await apiFetchWithParams<ApiProps>('./props', {
+ *   model: 'gpt-4',
+ *   autoload: 'false'
+ * });
+ * ```
+ */
+export async function apiFetchWithParams<T>(
+       basePath: string,
+       params: Record<string, string>,
+       options: ApiFetchOptions = {}
+): Promise<T> {
+       const url = new URL(basePath, window.location.href);
+
+       for (const [key, value] of Object.entries(params)) {
+               if (value !== undefined && value !== null) {
+                       url.searchParams.set(key, value);
+               }
+       }
+
+       const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
+
+       const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
+       const headers = { ...baseHeaders, ...customHeaders };
+
+       const response = await fetch(url.toString(), {
+               ...fetchOptions,
+               headers
+       });
+
+       if (!response.ok) {
+               const errorMessage = await parseErrorMessage(response);
+               throw new Error(errorMessage);
+       }
+
+       return response.json() as Promise<T>;
+}
+
+/**
+ * POST JSON data to an API endpoint.
+ *
+ * @param path - API path
+ * @param body - Request body (will be JSON stringified)
+ * @param options - Additional fetch options
+ * @returns Parsed JSON response
+ */
+export async function apiPost<T, B = unknown>(
+       path: string,
+       body: B,
+       options: ApiFetchOptions = {}
+): Promise<T> {
+       return apiFetch<T>(path, {
+               method: 'POST',
+               body: JSON.stringify(body),
+               ...options
+       });
+}
+
+/**
+ * Parse error message from a failed response.
+ * Tries to extract error message from JSON body, falls back to status text.
+ */
+async function parseErrorMessage(response: Response): Promise<string> {
+       try {
+               const errorData = await response.json();
+               if (errorData?.error?.message) {
+                       return errorData.error.message;
+               }
+               if (errorData?.error && typeof errorData.error === 'string') {
+                       return errorData.error;
+               }
+               if (errorData?.message) {
+                       return errorData.message;
+               }
+       } catch {
+               // JSON parsing failed, use status text
+       }
+
+       return `Request failed: ${response.status} ${response.statusText}`;
+}
index 3be56047a59efd808b1b25405c18f68f2a6b61e5..ee3a505eedbff3f3d27ff7b025f35edb3727479c 100644 (file)
@@ -15,6 +15,8 @@
  *        â””── message 5 (assistant)
  */
 
+import { MessageRole } from '$lib/enums/chat';
+
 /**
  * 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.
@@ -65,8 +67,13 @@ export function filterByLeafNodeId(
                currentNode = nodeMap.get(currentNode.parent);
        }
 
-       // Sort by timestamp to get chronological order (root to leaf)
-       result.sort((a, b) => a.timestamp - b.timestamp);
+       // Sort: system messages first, then by timestamp
+       result.sort((a, b) => {
+               if (a.role === MessageRole.SYSTEM && b.role !== MessageRole.SYSTEM) return -1;
+               if (a.role !== MessageRole.SYSTEM && b.role === MessageRole.SYSTEM) return 1;
+
+               return a.timestamp - b.timestamp;
+       });
        return result;
 }
 
index 0af800638baf6ade2052de4eca6215cc517ca212..27d2be4aaae45b346c9140475739f63fb0ab2b28 100644 (file)
@@ -23,7 +23,7 @@ export {
 } from './pdf-processing';
 
 // File conversion utilities (depends on pdf-processing)
-export { parseFilesToMessageExtras, type FileProcessingResult } from './convert-files-to-extra';
+export { parseFilesToMessageExtras } from './convert-files-to-extra';
 
 // File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png)
 export { processFilesToChatUploaded } from './process-uploaded-files';
diff --git a/tools/server/webui/src/lib/utils/cache-ttl.ts b/tools/server/webui/src/lib/utils/cache-ttl.ts
new file mode 100644 (file)
index 0000000..9d1f005
--- /dev/null
@@ -0,0 +1,293 @@
+const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
+const DEFAULT_CACHE_MAX_ENTRIES = 100;
+
+/**
+ * TTL Cache - Time-To-Live cache implementation for memory optimization
+ *
+ * Provides automatic expiration of cached entries to prevent memory bloat
+ * in long-running sessions.
+ *
+ * @example
+ * ```ts
+ * const cache = new TTLCache<string, ApiData>({ ttlMs: 5 * 60 * 1000 }); // 5 minutes
+ * cache.set('key', data);
+ * const value = cache.get('key'); // null if expired
+ * ```
+ */
+
+export interface TTLCacheOptions {
+       /** Time-to-live in milliseconds. Default: 5 minutes */
+       ttlMs?: number;
+       /** Maximum number of entries. Oldest entries are evicted when exceeded. Default: 100 */
+       maxEntries?: number;
+       /** Callback when an entry expires or is evicted */
+       onEvict?: (key: string, value: unknown) => void;
+}
+
+interface CacheEntry<T> {
+       value: T;
+       expiresAt: number;
+       lastAccessed: number;
+}
+
+export class TTLCache<K extends string, V> {
+       private cache = new Map<K, CacheEntry<V>>();
+       private readonly ttlMs: number;
+       private readonly maxEntries: number;
+       private readonly onEvict?: (key: string, value: unknown) => void;
+
+       constructor(options: TTLCacheOptions = {}) {
+               this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
+               this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
+               this.onEvict = options.onEvict;
+       }
+
+       /**
+        * Get a value from cache. Returns null if expired or not found.
+        */
+       get(key: K): V | null {
+               const entry = this.cache.get(key);
+               if (!entry) return null;
+
+               if (Date.now() > entry.expiresAt) {
+                       this.delete(key);
+                       return null;
+               }
+
+               // Update last accessed time for LRU-like behavior
+               entry.lastAccessed = Date.now();
+               return entry.value;
+       }
+
+       /**
+        * Set a value in cache with TTL.
+        */
+       set(key: K, value: V, customTtlMs?: number): void {
+               // Evict oldest entries if at capacity
+               if (this.cache.size >= this.maxEntries && !this.cache.has(key)) {
+                       this.evictOldest();
+               }
+
+               const ttl = customTtlMs ?? this.ttlMs;
+               const now = Date.now();
+
+               this.cache.set(key, {
+                       value,
+                       expiresAt: now + ttl,
+                       lastAccessed: now
+               });
+       }
+
+       /**
+        * Check if key exists and is not expired.
+        */
+       has(key: K): boolean {
+               const entry = this.cache.get(key);
+               if (!entry) return false;
+
+               if (Date.now() > entry.expiresAt) {
+                       this.delete(key);
+                       return false;
+               }
+
+               return true;
+       }
+
+       /**
+        * Delete a specific key from cache.
+        */
+       delete(key: K): boolean {
+               const entry = this.cache.get(key);
+               if (entry && this.onEvict) {
+                       this.onEvict(key, entry.value);
+               }
+               return this.cache.delete(key);
+       }
+
+       /**
+        * Clear all entries from cache.
+        */
+       clear(): void {
+               if (this.onEvict) {
+                       for (const [key, entry] of this.cache) {
+                               this.onEvict(key, entry.value);
+                       }
+               }
+               this.cache.clear();
+       }
+
+       /**
+        * Get the number of entries (including potentially expired ones).
+        */
+       get size(): number {
+               return this.cache.size;
+       }
+
+       /**
+        * Remove all expired entries from cache.
+        * Call periodically for proactive cleanup.
+        */
+       prune(): number {
+               const now = Date.now();
+               let pruned = 0;
+
+               for (const [key, entry] of this.cache) {
+                       if (now > entry.expiresAt) {
+                               this.delete(key);
+                               pruned++;
+                       }
+               }
+
+               return pruned;
+       }
+
+       /**
+        * Get all valid (non-expired) keys.
+        */
+       keys(): K[] {
+               const now = Date.now();
+               const validKeys: K[] = [];
+
+               for (const [key, entry] of this.cache) {
+                       if (now <= entry.expiresAt) {
+                               validKeys.push(key);
+                       }
+               }
+
+               return validKeys;
+       }
+
+       /**
+        * Evict the oldest (least recently accessed) entry.
+        */
+       private evictOldest(): void {
+               let oldestKey: K | null = null;
+               let oldestTime = Infinity;
+
+               for (const [key, entry] of this.cache) {
+                       if (entry.lastAccessed < oldestTime) {
+                               oldestTime = entry.lastAccessed;
+                               oldestKey = key;
+                       }
+               }
+
+               if (oldestKey !== null) {
+                       this.delete(oldestKey);
+               }
+       }
+
+       /**
+        * Refresh TTL for an existing entry without changing the value.
+        */
+       touch(key: K): boolean {
+               const entry = this.cache.get(key);
+               if (!entry) return false;
+
+               const now = Date.now();
+               if (now > entry.expiresAt) {
+                       this.delete(key);
+                       return false;
+               }
+
+               entry.expiresAt = now + this.ttlMs;
+               entry.lastAccessed = now;
+               return true;
+       }
+}
+
+/**
+ * Reactive TTL Map for Svelte stores
+ * Wraps SvelteMap with TTL functionality
+ */
+export class ReactiveTTLMap<K extends string, V> {
+       private entries = $state<Map<K, CacheEntry<V>>>(new Map());
+       private readonly ttlMs: number;
+       private readonly maxEntries: number;
+
+       constructor(options: TTLCacheOptions = {}) {
+               this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
+               this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
+       }
+
+       get(key: K): V | null {
+               const entry = this.entries.get(key);
+               if (!entry) return null;
+
+               if (Date.now() > entry.expiresAt) {
+                       this.entries.delete(key);
+                       return null;
+               }
+
+               entry.lastAccessed = Date.now();
+               return entry.value;
+       }
+
+       set(key: K, value: V, customTtlMs?: number): void {
+               if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
+                       this.evictOldest();
+               }
+
+               const ttl = customTtlMs ?? this.ttlMs;
+               const now = Date.now();
+
+               this.entries.set(key, {
+                       value,
+                       expiresAt: now + ttl,
+                       lastAccessed: now
+               });
+       }
+
+       has(key: K): boolean {
+               const entry = this.entries.get(key);
+               if (!entry) return false;
+
+               if (Date.now() > entry.expiresAt) {
+                       this.entries.delete(key);
+                       return false;
+               }
+
+               return true;
+       }
+
+       delete(key: K): boolean {
+               return this.entries.delete(key);
+       }
+
+       clear(): void {
+               this.entries.clear();
+       }
+
+       get size(): number {
+               return this.entries.size;
+       }
+
+       prune(): number {
+               const now = Date.now();
+               let pruned = 0;
+
+               for (const [key, entry] of this.entries) {
+                       if (now > entry.expiresAt) {
+                               this.entries.delete(key);
+                               pruned++;
+                       }
+               }
+
+               return pruned;
+       }
+
+       private evictOldest(): void {
+               let oldestKey: K | null = null;
+               let oldestTime = Infinity;
+
+               for (const [key, entry] of this.entries) {
+                       if (entry.lastAccessed < oldestTime) {
+                               oldestTime = entry.lastAccessed;
+                               oldestKey = key;
+                       }
+               }
+
+               if (oldestKey !== null) {
+                       this.entries.delete(oldestKey);
+               }
+       }
+}
diff --git a/tools/server/webui/src/lib/utils/code.ts b/tools/server/webui/src/lib/utils/code.ts
new file mode 100644 (file)
index 0000000..67efc6b
--- /dev/null
@@ -0,0 +1,85 @@
+import hljs from 'highlight.js';
+import {
+       NEWLINE,
+       DEFAULT_LANGUAGE,
+       LANG_PATTERN,
+       AMPERSAND_REGEX,
+       LT_REGEX,
+       GT_REGEX,
+       FENCE_PATTERN
+} from '$lib/constants/code';
+
+export interface IncompleteCodeBlock {
+       language: string;
+       code: string;
+       openingIndex: number;
+}
+
+/**
+ * Highlights code using highlight.js
+ * @param code - The code to highlight
+ * @param language - The programming language
+ * @returns HTML string with syntax highlighting
+ */
+export function highlightCode(code: string, language: string): string {
+       if (!code) return '';
+
+       try {
+               const lang = language.toLowerCase();
+               const isSupported = hljs.getLanguage(lang);
+
+               if (isSupported) {
+                       return hljs.highlight(code, { language: lang }).value;
+               } else {
+                       return hljs.highlightAuto(code).value;
+               }
+       } catch {
+               // Fallback to escaped plain text
+               return code
+                       .replace(AMPERSAND_REGEX, '&amp;')
+                       .replace(LT_REGEX, '&lt;')
+                       .replace(GT_REGEX, '&gt;');
+       }
+}
+
+/**
+ * Detects if markdown ends with an incomplete code block (opened but not closed).
+ * Returns the code block info if found, null otherwise.
+ * @param markdown - The raw markdown string to check
+ * @returns IncompleteCodeBlock info or null
+ */
+export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock | null {
+       // Count all code fences in the markdown
+       // A code block is incomplete if there's an odd number of ``` fences
+       const fencePattern = new RegExp(FENCE_PATTERN.source, FENCE_PATTERN.flags);
+       const fences: number[] = [];
+       let fenceMatch;
+
+       while ((fenceMatch = fencePattern.exec(markdown)) !== null) {
+               // Store the position after the ```
+               const pos = fenceMatch[0].startsWith(NEWLINE) ? fenceMatch.index + 1 : fenceMatch.index;
+               fences.push(pos);
+       }
+
+       // If even number of fences (including 0), all code blocks are closed
+       if (fences.length % 2 === 0) {
+               return null;
+       }
+
+       // Odd number means last code block is incomplete
+       // The last fence is the opening of the incomplete block
+       const openingIndex = fences[fences.length - 1];
+       const afterOpening = markdown.slice(openingIndex + 3);
+
+       // Extract language and code content
+       const langMatch = afterOpening.match(LANG_PATTERN);
+       const language = langMatch?.[1] || DEFAULT_LANGUAGE;
+       const codeStartIndex = openingIndex + 3 + (langMatch?.[0]?.length ?? 0);
+       const code = markdown.slice(codeStartIndex);
+
+       return {
+               language,
+               code,
+               openingIndex
+       };
+}
diff --git a/tools/server/webui/src/lib/utils/data-url.ts b/tools/server/webui/src/lib/utils/data-url.ts
new file mode 100644 (file)
index 0000000..6f55be7
--- /dev/null
@@ -0,0 +1,10 @@
+/**
+ * Creates a base64 data URL from MIME type and base64-encoded data.
+ *
+ * @param mimeType - The MIME type (e.g., 'image/png', 'audio/mp3')
+ * @param base64Data - The base64-encoded data
+ * @returns A data URL string in format 'data:{mimeType};base64,{data}'
+ */
+export function createBase64DataUrl(mimeType: string, base64Data: string): string {
+       return `data:${mimeType};base64,${base64Data}`;
+}
diff --git a/tools/server/webui/src/lib/utils/debounce.ts b/tools/server/webui/src/lib/utils/debounce.ts
new file mode 100644 (file)
index 0000000..90a5a01
--- /dev/null
@@ -0,0 +1,22 @@
+/**
+ * @param fn - The function to debounce
+ * @param delay - The delay in milliseconds
+ * @returns A debounced version of the function
+ */
+export function debounce<T extends (...args: Parameters<T>) => void>(
+       fn: T,
+       delay: number
+): (...args: Parameters<T>) => void {
+       let timeoutId: ReturnType<typeof setTimeout> | null = null;
+
+       return (...args: Parameters<T>) => {
+               if (timeoutId) {
+                       clearTimeout(timeoutId);
+               }
+
+               timeoutId = setTimeout(() => {
+                       fn(...args);
+                       timeoutId = null;
+               }, delay);
+       };
+}
diff --git a/tools/server/webui/src/lib/utils/image-error-fallback.ts b/tools/server/webui/src/lib/utils/image-error-fallback.ts
new file mode 100644 (file)
index 0000000..6e3260f
--- /dev/null
@@ -0,0 +1,10 @@
+/**
+ * Simplified HTML fallback for external images that fail to load.
+ * Displays a centered message with a link to open the image in a new tab.
+ */
+export function getImageErrorFallbackHtml(src: string): string {
+       return `<div class="image-error-content">
+               <span>Image cannot be displayed</span>
+               <a href="${src}" target="_blank" rel="noopener noreferrer">(open link)</a>
+       </div>`;
+}
index 588167b8ca4fb7ae4c9408885c4e3dcc1a4dffc4..38e809f2d3cf4b282464c7ec28ab412a1fc004ac 100644 (file)
@@ -9,6 +9,7 @@
 
 // API utilities
 export { getAuthHeaders, getJsonHeaders } from './api-headers';
+export { apiFetch, apiFetchWithParams, apiPost, type ApiFetchOptions } from './api-fetch';
 export { validateApiKey } from './api-key-validation';
 
 // Attachment utilities
@@ -75,8 +76,7 @@ export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection';
 export {
        isFileTypeSupportedByModel,
        filterFilesByModalities,
-       generateModalityErrorMessage,
-       type ModalityCapabilities
+       generateModalityErrorMessage
 } from './modality-file-validation';
 
 // Model name utilities
index 136c084146773661da197a18cd75922b9a8a7b2e..02fb4e4a365ff2a8961fef0c236bd3873cea8028 100644 (file)
@@ -5,12 +5,7 @@
 
 import { getFileTypeCategory } from '$lib/utils';
 import { FileTypeCategory } from '$lib/enums';
-
-/** Modality capabilities for file validation */
-export interface ModalityCapabilities {
-       hasVision: boolean;
-       hasAudio: boolean;
-}
+import type { ModalityCapabilities } from '$lib/types/models';
 
 /**
  * Check if a file type is supported by the given modalities
index e8006de64d3ca4ca711377bb7d410afc08f13607..2f1a575d1dd1294a2f2e29f8ce6359b45b4ab276 100644 (file)
@@ -3,10 +3,8 @@
  * Handles text file detection, reading, and validation
  */
 
-import {
-       DEFAULT_BINARY_DETECTION_OPTIONS,
-       type BinaryDetectionOptions
-} from '$lib/constants/binary-detection';
+import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants/binary-detection';
+import type { BinaryDetectionOptions } from '$lib/constants/binary-detection';
 import { FileExtensionText } from '$lib/enums';
 
 /**