From 54ce507b6fded72c1819d9ba6b092d35a7690c0e Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 24 Jul 2026 15:09:55 +0200 Subject: [PATCH] UI: Fix settings precedence, Factory < Admin (--ui-config-file) < Users (Settings panel) (#26002) --- tools/ui/src/lib/constants/mcp.ts | 3 + .../ui/src/lib/constants/settings-registry.ts | 206 +++--------------- tools/ui/src/lib/enums/files.enums.ts | 1 + .../services/parameter-sync.service.spec.ts | 18 +- .../lib/services/parameter-sync.service.ts | 16 +- tools/ui/src/lib/stores/chat.svelte.ts | 6 +- tools/ui/src/lib/stores/settings.svelte.ts | 44 +++- tools/ui/src/lib/utils/api-headers.ts | 4 +- tools/ui/src/lib/utils/api-key-validation.ts | 20 +- tools/ui/src/routes/+layout.svelte | 5 +- .../tests/client/apikey-splash.svelte.test.ts | 41 ++++ ...ettings-registry-invariants.svelte.test.ts | 107 +++++++++ .../client/ui-settings-sync.svelte.test.ts | 73 +++++++ 13 files changed, 325 insertions(+), 219 deletions(-) create mode 100644 tools/ui/tests/client/apikey-splash.svelte.test.ts create mode 100644 tools/ui/tests/client/settings-registry-invariants.svelte.test.ts create mode 100644 tools/ui/tests/client/ui-settings-sync.svelte.test.ts diff --git a/tools/ui/src/lib/constants/mcp.ts b/tools/ui/src/lib/constants/mcp.ts index 0794883e8..f4979564d 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/constants/settings-registry.ts b/tools/ui/src/lib/constants/settings-registry.ts index 742b5f5c6..4d52146c8 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.SYMBOLIC_MATH_ENABLED, @@ -747,11 +626,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 } ] } @@ -763,30 +638,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, @@ -795,7 +661,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/enums/files.enums.ts b/tools/ui/src/lib/enums/files.enums.ts index a908b8996..eecb36c23 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/services/parameter-sync.service.spec.ts b/tools/ui/src/lib/services/parameter-sync.service.spec.ts index f15c0c143..2e7fcd521 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 900471b3a..becaa1298 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/chat.svelte.ts b/tools/ui/src/lib/stores/chat.svelte.ts index 801d6c838..5cbfe213b 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/stores/settings.svelte.ts b/tools/ui/src/lib/stores/settings.svelte.ts index 8d3c711b8..df45f0503 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,27 @@ class SettingsStore { this.userOverrides.delete(key); } + // 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; + } + + const value = + uiSettings && key in uiSettings && uiSettings[key] !== undefined + ? uiSettings[key] + : getConfigValue(SETTING_CONFIG_DEFAULT, key); + + 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/src/lib/utils/api-headers.ts b/tools/ui/src/lib/utils/api-headers.ts index 1aa15e09d..da0ec9db5 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 6b37dd58c..5f5986f9c 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'; /** @@ -15,19 +16,18 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise = { - 'Content-Type': 'application/json', - [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey}` + [CONTENT_TYPE_HEADER]: MimeTypeApplication.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 e25f141d1..ad2dd1560 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; } 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 000000000..39389f3d3 --- /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'); + }); +}); 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 000000000..74817234e --- /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]); + } + } + }); +}); 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 000000000..e2ba3c2da --- /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); + }); +});