diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz
index e3b06f4901..327386f413 100644
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
diff --git a/tools/server/webui/src/lib/constants/binary-detection.ts b/tools/server/webui/src/lib/constants/binary-detection.ts
index a4440fde5d..eac919ad96 100644
--- a/tools/server/webui/src/lib/constants/binary-detection.ts
+++ b/tools/server/webui/src/lib/constants/binary-detection.ts
@@ -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
index 0000000000..c5e3dc3d1b
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/chat-form.ts
@@ -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
index 0000000000..0f7265104d
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/code-blocks.ts
@@ -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
index 0000000000..12bcd0db77
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/code.ts
@@ -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 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
index 0000000000..46076e55f6
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/css-classes.ts
@@ -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
index 0000000000..d6d1b883ff
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/formatters.ts
@@ -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
index 0000000000..783d31a22c
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/markdown.ts
@@ -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';
diff --git a/tools/server/webui/src/lib/constants/processing-info.ts b/tools/server/webui/src/lib/constants/processing-info.ts
index 726439211b..2c3f7dc534 100644
--- a/tools/server/webui/src/lib/constants/processing-info.ts
+++ b/tools/server/webui/src/lib/constants/processing-info.ts
@@ -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
index 0000000000..79a6e92870
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/settings-fields.ts
@@ -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;
diff --git a/tools/server/webui/src/lib/constants/tooltip-config.ts b/tools/server/webui/src/lib/constants/tooltip-config.ts
index 3c30c8c072..ad76ab3522 100644
--- a/tools/server/webui/src/lib/constants/tooltip-config.ts
+++ b/tools/server/webui/src/lib/constants/tooltip-config.ts
@@ -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
index 0000000000..a75b30f2f8
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/ui.ts
@@ -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
index 0000000000..eba0fec027
--- /dev/null
+++ b/tools/server/webui/src/lib/contexts/chat-actions.context.ts
@@ -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
index 0000000000..73ff6f96fa
--- /dev/null
+++ b/tools/server/webui/src/lib/contexts/index.ts
@@ -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
index 0000000000..7af116daa5
--- /dev/null
+++ b/tools/server/webui/src/lib/contexts/message-edit.context.ts
@@ -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);
+}
diff --git a/tools/server/webui/src/lib/enums/chat.ts b/tools/server/webui/src/lib/enums/chat.ts
index 2b9eb7bc2e..0b6f357d9a 100644
--- a/tools/server/webui/src/lib/enums/chat.ts
+++ b/tools/server/webui/src/lib/enums/chat.ts
@@ -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
index 0000000000..b8f6d5f7a2
--- /dev/null
+++ b/tools/server/webui/src/lib/enums/keyboard.ts
@@ -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
index 0000000000..f17f219762
--- /dev/null
+++ b/tools/server/webui/src/lib/enums/settings.ts
@@ -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
index 0000000000..bbaa5d1362
--- /dev/null
+++ b/tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts
@@ -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 | undefined;
+ private _scrollTimeout: ReturnType | 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);
+}
diff --git a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts b/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
index c06cf28864..068440cdc0 100644
--- a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
+++ b/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
@@ -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,31 @@ 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 (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 +189,57 @@ 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) {
+ 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 (
+ 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) {
@@ -251,6 +327,7 @@ export function useProcessingState(): UseProcessingStateReturn {
return processingState;
},
getProcessingDetails,
+ getTechnicalDetails,
getProcessingMessage,
getPromptProgressText,
getLiveProcessingStats,
diff --git a/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts b/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts
index 6f0e03e211..168de97403 100644
--- a/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts
+++ b/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts
@@ -13,6 +13,16 @@
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
index 0000000000..0d5a9c1b99
--- /dev/null
+++ b/tools/server/webui/src/lib/services/database.service.ts
@@ -0,0 +1,368 @@
+import Dexie, { type EntityTable } from 'dexie';
+import { findDescendantMessages } from '$lib/utils';
+
+class LlamacppDatabase extends Dexie {
+ conversations!: EntityTable;
+ messages!: EntityTable;
+
+ 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 {
+ 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,
+ parentId: string | null
+ ): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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>
+ ): Promise {
+ 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 {
+ 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>
+ ): Promise {
+ 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
index 0000000000..7357c3f400
--- /dev/null
+++ b/tools/server/webui/src/lib/services/models.service.ts
@@ -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 {
+ return apiFetch('/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 {
+ return apiFetch('/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 {
+ const payload: { model: string; extra_args?: string[] } = { model: modelId };
+ if (extraArgs && extraArgs.length > 0) {
+ payload.extra_args = extraArgs;
+ }
+
+ return apiPost('/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 {
+ return apiPost('/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
index 0000000000..46cce5e7cb
--- /dev/null
+++ b/tools/server/webui/src/lib/services/parameter-sync.service.spec.ts
@@ -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
index 0000000000..6cb53d12d1
--- /dev/null
+++ b/tools/server/webui/src/lib/services/parameter-sync.service.ts
@@ -0,0 +1,400 @@
+import { normalizeFloatingPoint } from '$lib/utils';
+import { SyncableParameterType, ParameterSource } from '$lib/enums/settings';
+
+type ParameterValue = string | number | boolean;
+type ParameterRecord = Record;
+
+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
+ ): 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)[
+ 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 = 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
+ ): 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 {
+ 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
index 0000000000..7373b7e016
--- /dev/null
+++ b/tools/server/webui/src/lib/services/props.service.ts
@@ -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 {
+ const params: Record = {};
+ if (!autoload) {
+ params.autoload = 'false';
+ }
+
+ return apiFetchWithParams('./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 {
+ const params: Record = { model: modelId };
+ if (!autoload) {
+ params.autoload = 'false';
+ }
+
+ return apiFetchWithParams('./props', params, { authOnly: true });
+ }
+}
diff --git a/tools/server/webui/src/lib/types/api.d.ts b/tools/server/webui/src/lib/types/api.d.ts
index 714509f024..307e3b71d9 100644
--- a/tools/server/webui/src/lib/types/api.d.ts
+++ b/tools/server/webui/src/lib/types/api.d.ts
@@ -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;
+}
+
+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;
diff --git a/tools/server/webui/src/lib/types/models.d.ts b/tools/server/webui/src/lib/types/models.d.ts
index ef44a2cb6d..505867a1f0 100644
--- a/tools/server/webui/src/lib/types/models.d.ts
+++ b/tools/server/webui/src/lib/types/models.d.ts
@@ -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
index 0000000000..fc4f31ec69
--- /dev/null
+++ b/tools/server/webui/src/lib/utils/abort.ts
@@ -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(promise: Promise, signal?: AbortSignal): Promise {
+ if (!signal) return promise;
+
+ throwIfAborted(signal);
+
+ return new Promise((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
index 0000000000..8081ef2ec2
--- /dev/null
+++ b/tools/server/webui/src/lib/utils/api-fetch.ts
@@ -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 {
+ /**
+ * 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;
+}
+
+/**
+ * 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('/v1/models');
+ *
+ * // POST request
+ * const result = await apiFetch('/models/load', {
+ * method: 'POST',
+ * body: JSON.stringify({ model: 'gpt-4' })
+ * });
+ * ```
+ */
+export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise {
+ 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;
+}
+
+/**
+ * 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('./props', {
+ * model: 'gpt-4',
+ * autoload: 'false'
+ * });
+ * ```
+ */
+export async function apiFetchWithParams(
+ basePath: string,
+ params: Record,
+ options: ApiFetchOptions = {}
+): Promise {
+ 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;
+}
+
+/**
+ * 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(
+ path: string,
+ body: B,
+ options: ApiFetchOptions = {}
+): Promise {
+ return apiFetch(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 {
+ 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}`;
+}
diff --git a/tools/server/webui/src/lib/utils/branching.ts b/tools/server/webui/src/lib/utils/branching.ts
index 3be56047a5..ee3a505eed 100644
--- a/tools/server/webui/src/lib/utils/branching.ts
+++ b/tools/server/webui/src/lib/utils/branching.ts
@@ -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;
}
diff --git a/tools/server/webui/src/lib/utils/browser-only.ts b/tools/server/webui/src/lib/utils/browser-only.ts
index 0af800638b..27d2be4aaa 100644
--- a/tools/server/webui/src/lib/utils/browser-only.ts
+++ b/tools/server/webui/src/lib/utils/browser-only.ts
@@ -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
index 0000000000..9d1f005822
--- /dev/null
+++ b/tools/server/webui/src/lib/utils/cache-ttl.ts
@@ -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({ 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 {
+ value: T;
+ expiresAt: number;
+ lastAccessed: number;
+}
+
+export class TTLCache {
+ private cache = new Map>();
+ 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 {
+ private entries = $state