]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
UI: Fix settings precedence, Factory < Admin (--ui-config-file) < Users (Settings...
authorPascal <redacted>
Fri, 24 Jul 2026 13:09:55 +0000 (15:09 +0200)
committerGitHub <redacted>
Fri, 24 Jul 2026 13:09:55 +0000 (15:09 +0200)
13 files changed:
tools/ui/src/lib/constants/mcp.ts
tools/ui/src/lib/constants/settings-registry.ts
tools/ui/src/lib/enums/files.enums.ts
tools/ui/src/lib/services/parameter-sync.service.spec.ts
tools/ui/src/lib/services/parameter-sync.service.ts
tools/ui/src/lib/stores/chat.svelte.ts
tools/ui/src/lib/stores/settings.svelte.ts
tools/ui/src/lib/utils/api-headers.ts
tools/ui/src/lib/utils/api-key-validation.ts
tools/ui/src/routes/+layout.svelte
tools/ui/tests/client/apikey-splash.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/client/settings-registry-invariants.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/client/ui-settings-sync.svelte.test.ts [new file with mode: 0644]

index 0794883e8fa90e844bda89c2e4e8073ab40736ef..f4979564d2510a34fdebee0e8ce0780263fd169d 100644 (file)
@@ -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',
index 742b5f5c67754c9e3ae23296aff5aa60d856731d..4d52146c8d279df6fa74214fab2c921711caf94e 100644 (file)
@@ -95,8 +95,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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<string, SettingsSectionEntry> = {
                                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 }
+       //
        // }
 ];
 
index a908b899657e947e514118ba840711be091a41c2..eecb36c23e63ca2b2e63897b5e007346d0d78a95 100644 (file)
@@ -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'
index f15c0c143379372ce653a82bf98285d51fcec213..2e7fcd5213d003f328c50ade6f8411b1c537f6d1 100644 (file)
@@ -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);
                });
        });
 });
index 900471b3aa9e1a360c1ff80ed3f394eeb51d897f..becaa1298abb07bd33e431d3964687beae7d142c 100644 (file)
@@ -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<string, string | number | boolean>
+               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;
        }
 
index 801d6c838f2e1afbee5dc89d1822036ee6c274ab..5cbfe213b104707d2bb63c3d5487b6804d1ab302 100644 (file)
@@ -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;
index 8d3c711b82e2805af473efcb22dccfe3da3216c7..df45f0503ab76f3cce5323db900211146a30778b 100644 (file)
@@ -64,6 +64,10 @@ class SettingsStore {
        isInitialized = $state(false);
        userOverrides = $state<Set<string>>(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<string, string | number | boolean> {
-               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();
        }
 
index 1aa15e09d310ae70850bb53f6680cd44528f91d3..da0ec9db5f0a499532502f7d24d747e4ef869a80 100644 (file)
@@ -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<string, string> {
  */
 export function getJsonHeaders(): Record<string, string> {
        return {
-               'Content-Type': 'application/json',
+               [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON,
                ...getAuthHeaders()
        };
 }
index 6b37dd58c16e0efd27b99b8134fefa915b4479fb..5f5986f9c2c28369c16e9e472006897a30bb81da 100644 (file)
@@ -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<vo
 
        const apiKey = config().apiKey;
 
-       // No API key configured — server doesn't require auth, skip the request entirely.
-       // The /props endpoint is only protected when the server has API keys configured,
-       // and in that case the client always has one set (from settings).
-       if (!apiKey) {
-               return;
-       }
-
        try {
                const headers: Record<string, string> = {
-                       '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) {
index e25f141d1e1536cc5de5834d28dfb5820dff2836..ad2dd1560ae9919496c2c21afa5f6b4512b0e477 100644 (file)
@@ -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 (file)
index 0000000..39389f3
--- /dev/null
@@ -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<string, string>)?.['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 (file)
index 0000000..7481723
--- /dev/null
@@ -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<string, Primitive>) {
+       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<string, unknown>)[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<string, Primitive> = {};
+               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<string, Primitive> = {};
+               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<string, Primitive> = {};
+               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<string, Primitive> = {};
+               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 (file)
index 0000000..e2ba3c2
--- /dev/null
@@ -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<string, string | number | boolean>) {
+       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);
+       });
+});