From 165ecc17ca4aca6811c86571b9395d99ae9712a0 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 22 Jul 2026 09:18:34 +0200 Subject: [PATCH 1/5] ui: settings sources, precedence, and application semantics Separate the three configuration sources cleanly: factory defaults, admin baseline (--ui-config-file / ui_settings), and the user's own config. The baseline is applied once for new users and on explicit Reset to Default (api key included), never on load, so the user's config diverges freely from the server file afterwards. The sync mechanism returns to its original scope: sampling parameters with a live server twin only, as a display and placeholder channel, never a config write path. UI settings are no longer declared as syncable parameters in the settings registry; only sampling parameters remain. --- .../ui/src/lib/constants/settings-registry.ts | 206 +++--------------- .../services/parameter-sync.service.spec.ts | 18 +- .../lib/services/parameter-sync.service.ts | 16 +- tools/ui/src/lib/stores/settings.svelte.ts | 41 +++- .../client/ui-settings-sync.svelte.test.ts | 73 +++++++ 5 files changed, 150 insertions(+), 204 deletions(-) create mode 100644 tools/ui/tests/client/ui-settings-sync.svelte.test.ts diff --git a/tools/ui/src/lib/constants/settings-registry.ts b/tools/ui/src/lib/constants/settings-registry.ts index f511b751de9f..37e3a61d0ef0 100644 --- a/tools/ui/src/lib/constants/settings-registry.ts +++ b/tools/ui/src/lib/constants/settings-registry.ts @@ -95,8 +95,7 @@ const SETTINGS_REGISTRY: Record = { defaultValue: ColorMode.SYSTEM, type: SettingsFieldType.SELECT, section: SETTINGS_SECTION_SLUGS.GENERAL, - options: COLOR_MODE_OPTIONS, - sync: { serverKey: SETTINGS_KEYS.THEME, paramType: SyncableParameterType.STRING } + options: COLOR_MODE_OPTIONS }, { key: SETTINGS_KEYS.API_KEY, @@ -112,11 +111,7 @@ const SETTINGS_REGISTRY: Record = { help: 'The starting message that defines how model should behave.', defaultValue: '', type: SettingsFieldType.TEXTAREA, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.SYSTEM_MESSAGE, - paramType: SyncableParameterType.STRING - } + section: SETTINGS_SECTION_SLUGS.GENERAL }, { key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN, @@ -124,11 +119,7 @@ const SETTINGS_REGISTRY: Record = { help: 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.', defaultValue: 2500, type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN, - paramType: SyncableParameterType.NUMBER - } + section: SETTINGS_SECTION_SLUGS.GENERAL }, { key: SETTINGS_KEYS.SEND_ON_ENTER, @@ -136,11 +127,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.', defaultValue: true, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.SEND_ON_ENTER, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.GENERAL }, { key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, @@ -149,11 +136,7 @@ const SETTINGS_REGISTRY: Record = { defaultValue: false, type: SettingsFieldType.CHECKBOX, section: SETTINGS_SECTION_SLUGS.GENERAL, - isExperimental: true, - sync: { - serverKey: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, - paramType: SyncableParameterType.BOOLEAN - } + isExperimental: true }, { key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION, @@ -162,22 +145,14 @@ const SETTINGS_REGISTRY: Record = { defaultValue: false, type: SettingsFieldType.CHECKBOX, section: SETTINGS_SECTION_SLUGS.GENERAL, - isExperimental: true, - sync: { - serverKey: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION, - paramType: SyncableParameterType.BOOLEAN - } + isExperimental: true }, { ...TITLE_GENERATION_BASE, key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE, label: 'Conversation title', help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.', - defaultValue: true, - sync: { - serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE, - paramType: SyncableParameterType.BOOLEAN - } + defaultValue: true }, { key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT, @@ -186,11 +161,7 @@ const SETTINGS_REGISTRY: Record = { defaultValue: TITLE_GENERATION.DEFAULT_PROMPT, type: SettingsFieldType.TEXTAREA, section: SETTINGS_SECTION_SLUGS.GENERAL, - dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM, - sync: { - serverKey: SETTINGS_KEYS.TITLE_GENERATION_PROMPT, - paramType: SyncableParameterType.STRING - } + dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM }, { key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT, @@ -198,11 +169,7 @@ const SETTINGS_REGISTRY: Record = { help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.GENERAL }, { key: SETTINGS_KEYS.PDF_AS_IMAGE, @@ -210,11 +177,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.PDF_AS_IMAGE, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.GENERAL }, { key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION, @@ -222,11 +185,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Images larger than this will be resized before sending to server. Set to 0 to disable.', defaultValue: 0, type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION, - paramType: SyncableParameterType.NUMBER - } + section: SETTINGS_SECTION_SLUGS.GENERAL } ] }, @@ -241,11 +200,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_MESSAGE_STATS, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.SHOW_AGENTIC_TURN_STATS, @@ -262,11 +217,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Expand thought process by default when generating messages.', defaultValue: true, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT, @@ -274,11 +225,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Automatically expand tool call details while executing and keep them expanded after completion.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN, @@ -286,11 +233,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Render user messages using markdown formatting in the chat.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN, @@ -298,11 +241,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.', defaultValue: true, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS, @@ -310,11 +249,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Always display code blocks at their full natural height, overriding any height limits.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL, @@ -322,11 +257,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.DISABLE_AUTO_SCROLL, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP, @@ -334,11 +265,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Always keep the sidebar visible on desktop instead of auto-hiding it.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES, @@ -346,11 +273,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.SHOW_MODEL_QUANTIZATION, @@ -358,11 +281,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Display quantization badges (e.g. Q8_0, Q4_K_M) next to model names throughout the interface.', defaultValue: true, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_MODEL_QUANTIZATION, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.SHOW_MODEL_TAGS, @@ -370,11 +289,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Display model tags (e.g. "vision", "reasoning") next to model names throughout the interface.', defaultValue: true, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_MODEL_TAGS, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY }, { key: SETTINGS_KEYS.SHOW_BUILD_VERSION, @@ -382,11 +297,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Display the current build version in the bottom-right corner of the interface.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_BUILD_VERSION, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DISPLAY } ] }, @@ -518,11 +429,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { - serverKey: SETTINGS_KEYS.BACKEND_SAMPLING, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.SAMPLING } ] }, @@ -638,11 +545,7 @@ const SETTINGS_REGISTRY: Record = { defaultValue: 10, type: SettingsFieldType.INPUT, section: SETTINGS_SECTION_SLUGS.AGENTIC, - isPositiveInteger: true, - sync: { - serverKey: SETTINGS_KEYS.AGENTIC_MAX_TURNS, - paramType: SyncableParameterType.NUMBER - } + isPositiveInteger: true }, { key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS, @@ -651,11 +554,7 @@ const SETTINGS_REGISTRY: Record = { defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, type: SettingsFieldType.INPUT, section: SETTINGS_SECTION_SLUGS.AGENTIC, - isPositiveInteger: true, - sync: { - serverKey: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS, - paramType: SyncableParameterType.NUMBER - } + isPositiveInteger: true } ] }, @@ -670,11 +569,7 @@ const SETTINGS_REGISTRY: Record = { help: 'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DEVELOPER, - sync: { - serverKey: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DEVELOPER }, { key: SETTINGS_KEYS.DISABLE_REASONING_PARSING, @@ -682,11 +577,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DEVELOPER, - sync: { - serverKey: SETTINGS_KEYS.DISABLE_REASONING_PARSING, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DEVELOPER }, { key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT, @@ -694,11 +585,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DEVELOPER, - sync: { - serverKey: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DEVELOPER }, { key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH, @@ -706,11 +593,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DEVELOPER, - sync: { - serverKey: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DEVELOPER }, { key: SETTINGS_KEYS.JS_SANDBOX_ENABLED, @@ -718,11 +601,7 @@ const SETTINGS_REGISTRY: Record = { help: 'Expose a run_javascript tool to the model. Code runs in a Web Worker inside a sandboxed iframe with an opaque origin, isolated from the WebUI and its API, with a hard timeout.', defaultValue: false, type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DEVELOPER, - sync: { - serverKey: SETTINGS_KEYS.JS_SANDBOX_ENABLED, - paramType: SyncableParameterType.BOOLEAN - } + section: SETTINGS_SECTION_SLUGS.DEVELOPER }, { key: SETTINGS_KEYS.CUSTOM_JSON, @@ -738,11 +617,7 @@ const SETTINGS_REGISTRY: Record = { help: 'CSS injected into the page at runtime. Set it here, or ship it server side via the --ui-config customCss field.', defaultValue: '', type: SettingsFieldType.TEXTAREA, - section: SETTINGS_SECTION_SLUGS.DEVELOPER, - sync: { - serverKey: SETTINGS_KEYS.CUSTOM_CSS, - paramType: SyncableParameterType.STRING - } + section: SETTINGS_SECTION_SLUGS.DEVELOPER } ] } @@ -754,30 +629,21 @@ const NON_UI_SETTINGS: SettingsEntry[] = [ label: 'Show system message', help: 'Display the system message at the top of each conversation.', defaultValue: true, - type: SettingsFieldType.CHECKBOX, - sync: { - serverKey: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, - paramType: SyncableParameterType.BOOLEAN - } + type: SettingsFieldType.CHECKBOX }, { key: SETTINGS_KEYS.MCP_SERVERS, label: 'MCP servers', help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.', defaultValue: '[]', - type: SettingsFieldType.INPUT, - sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING } + type: SettingsFieldType.INPUT }, { key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM, label: 'Generate title with LLM', help: 'Counterpart of the conversation title radio; stored and synced without a dedicated UI field.', defaultValue: false, - type: SettingsFieldType.CHECKBOX, - sync: { - serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM, - paramType: SyncableParameterType.BOOLEAN - } + type: SettingsFieldType.CHECKBOX } // { // key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, @@ -786,7 +652,7 @@ const NON_UI_SETTINGS: SettingsEntry[] = [ // defaultValue: false, // type: SettingsFieldType.CHECKBOX, // isExperimental: true, - // sync: { serverKey: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, paramType: SyncableParameterType.BOOLEAN } + // // } ]; diff --git a/tools/ui/src/lib/services/parameter-sync.service.spec.ts b/tools/ui/src/lib/services/parameter-sync.service.spec.ts index f15c0c143379..2e7fcd5213d0 100644 --- a/tools/ui/src/lib/services/parameter-sync.service.spec.ts +++ b/tools/ui/src/lib/services/parameter-sync.service.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; import { ParameterSyncService } from './parameter-sync.service'; -import { ColorMode } from '$lib/enums'; describe('ParameterSyncService', () => { describe('roundFloatingPoint', () => { @@ -132,18 +131,13 @@ describe('ParameterSyncService', () => { expect(result.temperature).toBe(0.7); }); - it('should merge ui settings from props when provided', () => { - const result = ParameterSyncService.extractServerDefaults(null, { - pasteLongTextToFileLen: 0, - pdfAsImage: true, - renderUserContentAsMarkdown: false, - theme: ColorMode.DARK - }); + it('extracts sampling twins only, never ui settings', () => { + const result = ParameterSyncService.extractServerDefaults(null); - expect(result.pasteLongTextToFileLen).toBe(0); - expect(result.pdfAsImage).toBe(true); - expect(result.renderUserContentAsMarkdown).toBe(false); - expect(result.theme).toBeUndefined(); + expect(result).toEqual({}); + expect(ParameterSyncService.canSyncParameter('theme')).toBe(false); + expect(ParameterSyncService.canSyncParameter('pdfAsImage')).toBe(false); + expect(ParameterSyncService.canSyncParameter('temperature')).toBe(true); }); }); }); diff --git a/tools/ui/src/lib/services/parameter-sync.service.ts b/tools/ui/src/lib/services/parameter-sync.service.ts index 900471b3aa9e..becaa1298abb 100644 --- a/tools/ui/src/lib/services/parameter-sync.service.ts +++ b/tools/ui/src/lib/services/parameter-sync.service.ts @@ -29,12 +29,10 @@ export class ParameterSyncService { * Converts samplers array to semicolon-delimited string for UI display. * * @param serverParams - Raw generation settings from server `/props` endpoint - * @param uiSettings - Optional UI-specific settings from server * @returns Record of extracted parameter key-value pairs with normalized precision */ static extractServerDefaults( - serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null, - uiSettings?: Record + serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null ): ParameterRecord { const extracted: ParameterRecord = {}; @@ -57,18 +55,6 @@ export class ParameterSyncService { } } - if (uiSettings) { - for (const param of SYNCABLE_PARAMETERS) { - if (param.canSync && param.serverKey in uiSettings) { - const value = uiSettings[param.serverKey]; - - if (value !== undefined) { - extracted[param.key] = this.roundFloatingPoint(value); - } - } - } - } - return extracted; } diff --git a/tools/ui/src/lib/stores/settings.svelte.ts b/tools/ui/src/lib/stores/settings.svelte.ts index 8d3c711b82e2..f6fd04d445fa 100644 --- a/tools/ui/src/lib/stores/settings.svelte.ts +++ b/tools/ui/src/lib/stores/settings.svelte.ts @@ -64,6 +64,10 @@ class SettingsStore { isInitialized = $state(false); userOverrides = $state>(new Set()); + // True until a config exists in localStorage; gates the one-time + // application of server ui_settings defaults for new users. + private isFirstVisit = false; + /** * * @@ -77,10 +81,7 @@ class SettingsStore { * Centralizes the pattern of getting and extracting server defaults */ private getServerDefaults(): Record { - const serverParams = serverStore.defaultParams; - const uiSettings = serverStore.uiSettings; - - return ParameterSyncService.extractServerDefaults(serverParams, uiSettings); + return ParameterSyncService.extractServerDefaults(serverStore.defaultParams); } constructor() { @@ -121,6 +122,11 @@ class SettingsStore { try { const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY); + + // First visit: no stored config yet. Server ui_settings apply once in + // this state, then the user's config diverges freely. + this.isFirstVisit = storedConfigRaw === null; + const savedVal = JSON.parse(storedConfigRaw || '{}'); // Merge with defaults to prevent breaking changes @@ -349,9 +355,12 @@ class SettingsStore { } } - // UI settings need actual values in config (no placeholder mechanism), - // so write them for non-overridden keys - if (uiSettings) { + // UI settings are the admin's defaults for new users: applied once on + // the first visit, never on later loads, so the user's config can + // diverge. "Reset to Default" is the explicit way back to the baseline. + if (uiSettings && this.isFirstVisit) { + this.isFirstVisit = false; + for (const [key, value] of Object.entries(uiSettings)) { if (!this.userOverrides.has(key) && value !== undefined) { setConfigValue(this.config, key, value); @@ -391,6 +400,24 @@ class SettingsStore { this.userOverrides.delete(key); } + // Non-syncable ui settings (theme, api key, custom css...) are part of + // the admin baseline too: an explicit reset reapplies them all. + if (uiSettings) { + for (const [key, value] of Object.entries(uiSettings)) { + if (ParameterSyncService.canSyncParameter(key) || value === undefined) { + continue; + } + + setConfigValue(this.config, key, value); + + if (key === SETTINGS_KEYS.THEME) { + setMode(value as ColorMode); + } + + this.userOverrides.delete(key); + } + } + this.saveConfig(); } diff --git a/tools/ui/tests/client/ui-settings-sync.svelte.test.ts b/tools/ui/tests/client/ui-settings-sync.svelte.test.ts new file mode 100644 index 000000000000..e2ba3c2dae78 --- /dev/null +++ b/tools/ui/tests/client/ui-settings-sync.svelte.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { settingsStore, config } from '$lib/stores/settings.svelte'; +import { serverStore } from '$lib/stores/server.svelte'; +import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage'; + +function mockProps(uiSettings: Record) { + Object.defineProperty(serverStore, 'props', { + configurable: true, + get: () => + ({ + default_generation_settings: { params: { temperature: 0.8 } }, + ui_settings: uiSettings + }) as unknown as typeof serverStore.props + }); +} + +describe('server ui_settings application semantics', () => { + beforeEach(() => { + localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY); + }); + + it('applies the admin defaults once for a new user', () => { + settingsStore.initialize(); + mockProps({ theme: 'dark', apiKey: '' }); + + settingsStore.syncWithServerDefaults(); + + expect(config().theme).toBe('dark'); + }); + + it('never reapplies on later loads: the user config diverges freely', () => { + settingsStore.initialize(); + settingsStore.updateConfig('theme', 'light'); + settingsStore.updateConfig('apiKey', 'sk-user-key'); + + // simulated F5: config now exists in localStorage + settingsStore.initialize(); + mockProps({ theme: 'dark', apiKey: '' }); + + settingsStore.syncWithServerDefaults(); + settingsStore.syncWithServerDefaults(); + + expect(config().theme).toBe('light'); + expect(config().apiKey).toBe('sk-user-key'); + const stored = JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}'); + expect(stored.apiKey).toBe('sk-user-key'); + }); + + it('Reset to Default reapplies the full baseline, api key included', () => { + settingsStore.initialize(); + settingsStore.updateConfig('theme', 'light'); + settingsStore.updateConfig('apiKey', 'sk-user-key'); + mockProps({ theme: 'dark', apiKey: '' }); + + settingsStore.forceSyncWithServerDefaults(); + + expect(config().theme).toBe('dark'); + expect(config().apiKey).toBe(''); + }); +}); + +describe('syncable scope (spec section 4)', () => { + it('sampling params keep their live server twin, ui settings carry none', async () => { + const { ParameterSyncService } = await import('$lib/services/parameter-sync.service'); + + expect(ParameterSyncService.canSyncParameter('temperature')).toBe(true); + expect(ParameterSyncService.canSyncParameter('samplers')).toBe(true); + expect(ParameterSyncService.canSyncParameter('theme')).toBe(false); + expect(ParameterSyncService.canSyncParameter('systemMessage')).toBe(false); + expect(ParameterSyncService.canSyncParameter('apiKey')).toBe(false); + expect(ParameterSyncService.canSyncParameter('customCss')).toBe(false); + }); +}); From f1893e2c307563e3a535bd69e48c4b5179b28dee Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 22 Jul 2026 14:51:57 +0200 Subject: [PATCH 2/5] ui: make Reset to Default a full return to instance state Reset now restores every key with precedence: admin baseline if defined, otherwise empty for sampling parameters (server decides), otherwise factory default. Previously, non-syncable keys absent from the baseline kept their user value. A registry-driven test locks the invariants for every key and value type. --- tools/ui/src/lib/stores/settings.svelte.ts | 27 +++-- ...ettings-registry-invariants.svelte.test.ts | 107 ++++++++++++++++++ 2 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 tools/ui/tests/client/settings-registry-invariants.svelte.test.ts diff --git a/tools/ui/src/lib/stores/settings.svelte.ts b/tools/ui/src/lib/stores/settings.svelte.ts index f6fd04d445fa..df45f0503ab7 100644 --- a/tools/ui/src/lib/stores/settings.svelte.ts +++ b/tools/ui/src/lib/stores/settings.svelte.ts @@ -400,22 +400,25 @@ class SettingsStore { this.userOverrides.delete(key); } - // Non-syncable ui settings (theme, api key, custom css...) are part of - // the admin baseline too: an explicit reset reapplies them all. - if (uiSettings) { - for (const [key, value] of Object.entries(uiSettings)) { - if (ParameterSyncService.canSyncParameter(key) || value === undefined) { - continue; - } + // Non-syncable keys: reset is a full return to the instance state, the + // admin baseline value when defined, the factory default otherwise. + for (const key of Object.keys(SETTING_CONFIG_DEFAULT)) { + if (ParameterSyncService.canSyncParameter(key)) { + continue; + } - setConfigValue(this.config, key, value); + const value = + uiSettings && key in uiSettings && uiSettings[key] !== undefined + ? uiSettings[key] + : getConfigValue(SETTING_CONFIG_DEFAULT, key); - if (key === SETTINGS_KEYS.THEME) { - setMode(value as ColorMode); - } + setConfigValue(this.config, key, value); - this.userOverrides.delete(key); + if (key === SETTINGS_KEYS.THEME) { + setMode(value as ColorMode); } + + this.userOverrides.delete(key); } this.saveConfig(); diff --git a/tools/ui/tests/client/settings-registry-invariants.svelte.test.ts b/tools/ui/tests/client/settings-registry-invariants.svelte.test.ts new file mode 100644 index 000000000000..74817234eb14 --- /dev/null +++ b/tools/ui/tests/client/settings-registry-invariants.svelte.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { settingsStore, config } from '$lib/stores/settings.svelte'; +import { serverStore } from '$lib/stores/server.svelte'; +import { ParameterSyncService } from '$lib/services/parameter-sync.service'; +import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-registry'; +import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage'; +import type { SettingsConfigType } from '$lib/types'; + +type Primitive = string | number | boolean; + +const KEYS = Object.keys(SETTING_CONFIG_DEFAULT).filter( + (k) => ['string', 'number', 'boolean'].includes(typeof SETTING_CONFIG_DEFAULT[k]) && k !== 'theme' +); + +function divergent(key: string, base: Primitive): Primitive { + if (typeof base === 'boolean') return !base; + if (typeof base === 'number') return base + 7; + return `user-${key}`; +} + +function baselineFor(key: string, base: Primitive): Primitive { + if (typeof base === 'boolean') return !base; + if (typeof base === 'number') return base + 42; + return `admin-${key}`; +} + +function mockProps(uiSettings: Record) { + Object.defineProperty(serverStore, 'props', { + configurable: true, + get: () => + ({ + default_generation_settings: { params: { temperature: 0.8 } }, + ui_settings: uiSettings + }) as unknown as typeof serverStore.props + }); +} + +const setUser = (key: string, value: Primitive) => + settingsStore.updateConfig(key as keyof SettingsConfigType, value as never); + +const current = (key: string) => (config() as Record)[key]; + +describe('registry-wide invariants', () => { + beforeEach(() => { + localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY); + }); + + it('I1: no load ever modifies a stored user value, for any key of any type', () => { + settingsStore.initialize(); + const userValues: Record = {}; + for (const key of KEYS) { + userValues[key] = divergent(key, SETTING_CONFIG_DEFAULT[key] as Primitive); + setUser(key, userValues[key]); + } + + // simulated F5 + adverse admin baseline on every key, synced twice + settingsStore.initialize(); + const adverse: Record = {}; + for (const key of KEYS) + adverse[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive); + mockProps(adverse); + settingsStore.syncWithServerDefaults(); + settingsStore.syncWithServerDefaults(); + + for (const key of KEYS) { + expect(current(key), key).toBe(userValues[key]); + } + }); + + it('first visit: the baseline applies for every key, false and 0 included', () => { + settingsStore.initialize(); + const baseline: Record = {}; + for (const key of KEYS) + baseline[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive); + mockProps(baseline); + + settingsStore.syncWithServerDefaults(); + + for (const key of KEYS) { + if (ParameterSyncService.canSyncParameter(key)) continue; + expect(current(key), key).toBe(baseline[key]); + } + }); + + it('I3: Reset returns every key to baseline when defined, factory default otherwise', () => { + settingsStore.initialize(); + for (const key of KEYS) setUser(key, divergent(key, SETTING_CONFIG_DEFAULT[key] as Primitive)); + + const baseline: Record = {}; + KEYS.filter((_, i) => i % 2 === 0).forEach((key) => { + baseline[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive); + }); + mockProps(baseline); + + settingsStore.forceSyncWithServerDefaults(); + + for (const key of KEYS) { + if (key in baseline) { + expect(current(key), key).toBe(baseline[key]); + } else if (ParameterSyncService.canSyncParameter(key)) { + expect(current(key), key).toBe(''); + } else { + expect(current(key), key).toBe(SETTING_CONFIG_DEFAULT[key]); + } + } + }); +}); From c9efc6bbc6847e7def1438238f92f407c044ba9c Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 22 Jul 2026 17:09:14 +0200 Subject: [PATCH 3/5] ui: show the api key splash when the server requires a key validateApiKey skipped the /props probe when no key was stored, so a keyed server produced silent failures instead of the api key splash. Probe without the authorization header in that case: the 401 surfaces the splash, which becomes the onboarding screen for entering the key. --- tools/ui/src/lib/utils/api-key-validation.ts | 17 ++++++++--------- tools/ui/src/routes/+layout.svelte | 5 +++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tools/ui/src/lib/utils/api-key-validation.ts b/tools/ui/src/lib/utils/api-key-validation.ts index 6b37dd58c16e..64ef77bdb753 100644 --- a/tools/ui/src/lib/utils/api-key-validation.ts +++ b/tools/ui/src/lib/utils/api-key-validation.ts @@ -15,19 +15,18 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise = { - 'Content-Type': 'application/json', - [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey}` + 'Content-Type': 'application/json' }; + // Probe /props even without a stored key: on a server started with + // --api-key the unauthenticated request returns 401 and surfaces the + // API key splash, which is the onboarding path for entering the key. + if (apiKey) { + headers[AUTHORIZATION_HEADER] = `${BEARER_PREFIX}${apiKey}`; + } + const response = await fetch(`${base}/props`, { headers }); if (!response.ok) { diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte index e25f141d1e15..ad2dd1560ae9 100644 --- a/tools/ui/src/routes/+layout.svelte +++ b/tools/ui/src/routes/+layout.svelte @@ -99,8 +99,9 @@ function checkApiKey() { const apiKey = config().apiKey; - // No API key configured — server doesn't require auth, no need to validate. - // This mirrors the early return in validateApiKey() to avoid redundant /props requests. + // Without a stored key there is nothing to re-validate here; the keyless + // 401 case is handled by validateApiKey() at navigation time, and the + // reload below must never fire in a keyless loop. if (!apiKey || apiKey.trim() === '') { return; } From ce0bd59ed957bdf4702a539ad0f7ea31bcdc9d43 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 22 Jul 2026 17:56:42 +0200 Subject: [PATCH 4/5] ui: add tests for api key validation splash behavior Cover the four validateApiKey cases with an injected fetch: keyed server with no stored key or a wrong key throws 401 and surfaces the splash, an open server passes silently, and a valid key passes with the bearer header sent. --- .../tests/client/apikey-splash.svelte.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tools/ui/tests/client/apikey-splash.svelte.test.ts diff --git a/tools/ui/tests/client/apikey-splash.svelte.test.ts b/tools/ui/tests/client/apikey-splash.svelte.test.ts new file mode 100644 index 000000000000..39389f3d39ee --- /dev/null +++ b/tools/ui/tests/client/apikey-splash.svelte.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { validateApiKey } from '$lib/utils/api-key-validation'; +import { settingsStore } from '$lib/stores/settings.svelte'; +import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage'; + +function fakeFetch(status: number, capture: { auth?: string | null } = {}) { + return (async (_url: RequestInfo | URL, init?: RequestInit) => { + capture.auth = (init?.headers as Record)?.['Authorization'] ?? null; + return new Response(status === 200 ? '{}' : 'Unauthorized', { status }); + }) as typeof globalThis.fetch; +} + +const is401 = (err: unknown) => + typeof err === 'object' && err !== null && 'status' in err && err.status === 401; + +describe('api key validation surfaces the splash', () => { + beforeEach(() => { + localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY); + settingsStore.initialize(); + }); + + it('keyed server, no stored key: throws 401 so the splash shows (onboarding)', async () => { + await expect(validateApiKey(fakeFetch(401))).rejects.toSatisfy(is401); + }); + + it('keyed server, wrong stored key: throws 401 so the splash shows', async () => { + settingsStore.updateConfig('apiKey', 'wrong-key'); + await expect(validateApiKey(fakeFetch(401))).rejects.toSatisfy(is401); + }); + + it('open server, no stored key: passes silently', async () => { + await expect(validateApiKey(fakeFetch(200))).resolves.toBeUndefined(); + }); + + it('valid stored key: passes and sends the bearer header', async () => { + settingsStore.updateConfig('apiKey', 'sk-good'); + const capture: { auth?: string | null } = {}; + await expect(validateApiKey(fakeFetch(200, capture))).resolves.toBeUndefined(); + expect(capture.auth).toBe('Bearer sk-good'); + }); +}); From 718b10a1f1eca8647cdd2e4b7a551cd6301fe47f Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 24 Jul 2026 11:10:47 +0200 Subject: [PATCH 5/5] ui: use constants for the Content-Type header Add MimeTypeApplication.JSON and a CONTENT_TYPE_HEADER constant next to AUTHORIZATION_HEADER, and replace the four hardcoded occurrences. --- tools/ui/src/lib/constants/mcp.ts | 3 +++ tools/ui/src/lib/enums/files.enums.ts | 1 + tools/ui/src/lib/stores/chat.svelte.ts | 6 ++++-- tools/ui/src/lib/utils/api-headers.ts | 4 +++- tools/ui/src/lib/utils/api-key-validation.ts | 5 +++-- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tools/ui/src/lib/constants/mcp.ts b/tools/ui/src/lib/constants/mcp.ts index 0794883e8fa9..f4979564d251 100644 --- a/tools/ui/src/lib/constants/mcp.ts +++ b/tools/ui/src/lib/constants/mcp.ts @@ -68,6 +68,9 @@ export const BEARER_PREFIX = 'Bearer '; /** Canonical casing for the Authorization header (RFC 7235) */ export const AUTHORIZATION_HEADER = 'Authorization'; +/** Content-Type HTTP header name */ +export const CONTENT_TYPE_HEADER = 'Content-Type'; + /** Header names whose values should be redacted in diagnostic logs */ export const REDACTED_HEADERS = new Set([ 'authorization', diff --git a/tools/ui/src/lib/enums/files.enums.ts b/tools/ui/src/lib/enums/files.enums.ts index a908b899657e..eecb36c23e63 100644 --- a/tools/ui/src/lib/enums/files.enums.ts +++ b/tools/ui/src/lib/enums/files.enums.ts @@ -180,6 +180,7 @@ export enum UriPattern { // MIME type enums export enum MimeTypeApplication { + JSON = 'application/json', PDF = 'application/pdf', OCTET_STREAM = 'application/octet-stream', ZIP = 'application/zip' diff --git a/tools/ui/src/lib/stores/chat.svelte.ts b/tools/ui/src/lib/stores/chat.svelte.ts index ab418ed9cdab..a2bb259fab98 100644 --- a/tools/ui/src/lib/stores/chat.svelte.ts +++ b/tools/ui/src/lib/stores/chat.svelte.ts @@ -16,6 +16,8 @@ import { DatabaseService } from '$lib/services/database.service'; import { ChatService } from '$lib/services/chat.service'; import { streamIdentity } from '$lib/utils/stream-identity'; import { getAuthHeaders } from '$lib/utils/api-headers'; +import { CONTENT_TYPE_HEADER } from '$lib/constants'; +import { MimeTypeApplication } from '$lib/enums'; import { conversationsStore } from '$lib/stores/conversations.svelte'; import { config } from '$lib/stores/settings.svelte'; import { agenticStore } from '$lib/stores/agentic.svelte'; @@ -208,7 +210,7 @@ class ChatStore { // POST the one conv id we are probing listResp = await fetch(`./v1/streams/lookup`, { method: 'POST', - headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, + headers: { ...getAuthHeaders(), [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON }, body: JSON.stringify({ conversation_ids: [convId] }) }); } catch (e) { @@ -672,7 +674,7 @@ class ChatStore { try { const resp = await fetch('./v1/streams/lookup', { method: 'POST', - headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, + headers: { ...getAuthHeaders(), [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON }, body: JSON.stringify({ conversation_ids: lookupIds }) }); if (!resp.ok) return; diff --git a/tools/ui/src/lib/utils/api-headers.ts b/tools/ui/src/lib/utils/api-headers.ts index 1aa15e09d310..da0ec9db5f0a 100644 --- a/tools/ui/src/lib/utils/api-headers.ts +++ b/tools/ui/src/lib/utils/api-headers.ts @@ -2,9 +2,11 @@ import { config } from '$lib/stores/settings.svelte'; import { AUTHORIZATION_HEADER, BEARER_PREFIX, + CONTENT_TYPE_HEADER, CORS_PROXY_HEADER_PREFIX, REDACTED_HEADERS } from '$lib/constants'; +import { MimeTypeApplication } from '$lib/enums'; import { redactValue } from './redact'; /** @@ -23,7 +25,7 @@ export function getAuthHeaders(): Record { */ export function getJsonHeaders(): Record { return { - 'Content-Type': 'application/json', + [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON, ...getAuthHeaders() }; } diff --git a/tools/ui/src/lib/utils/api-key-validation.ts b/tools/ui/src/lib/utils/api-key-validation.ts index 64ef77bdb753..5f5986f9c2c2 100644 --- a/tools/ui/src/lib/utils/api-key-validation.ts +++ b/tools/ui/src/lib/utils/api-key-validation.ts @@ -1,7 +1,8 @@ import { base } from '$app/paths'; import { error } from '@sveltejs/kit'; import { browser } from '$app/environment'; -import { AUTHORIZATION_HEADER, BEARER_PREFIX } from '$lib/constants'; +import { AUTHORIZATION_HEADER, BEARER_PREFIX, CONTENT_TYPE_HEADER } from '$lib/constants'; +import { MimeTypeApplication } from '$lib/enums'; import { config } from '$lib/stores/settings.svelte'; /** @@ -17,7 +18,7 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise = { - 'Content-Type': 'application/json' + [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON }; // Probe /props even without a stored key: on a server started with