UI: Fix settings precedence, Factory < Admin (--ui-config-file) < Users (Settings panel) (#26002)

This commit is contained in:
Pascal
2026-07-24 15:09:55 +02:00
committed by GitHub
parent 8f5ab832ca
commit 54ce507b6f
13 changed files with 325 additions and 219 deletions
+3
View File
@@ -68,6 +68,9 @@ export const BEARER_PREFIX = 'Bearer ';
/** Canonical casing for the Authorization header (RFC 7235) */ /** Canonical casing for the Authorization header (RFC 7235) */
export const AUTHORIZATION_HEADER = 'Authorization'; 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 */ /** Header names whose values should be redacted in diagnostic logs */
export const REDACTED_HEADERS = new Set([ export const REDACTED_HEADERS = new Set([
'authorization', 'authorization',
+36 -170
View File
@@ -95,8 +95,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
defaultValue: ColorMode.SYSTEM, defaultValue: ColorMode.SYSTEM,
type: SettingsFieldType.SELECT, type: SettingsFieldType.SELECT,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL,
options: COLOR_MODE_OPTIONS, options: COLOR_MODE_OPTIONS
sync: { serverKey: SETTINGS_KEYS.THEME, paramType: SyncableParameterType.STRING }
}, },
{ {
key: SETTINGS_KEYS.API_KEY, 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.', help: 'The starting message that defines how model should behave.',
defaultValue: '', defaultValue: '',
type: SettingsFieldType.TEXTAREA, type: SettingsFieldType.TEXTAREA,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL
sync: {
serverKey: SETTINGS_KEYS.SYSTEM_MESSAGE,
paramType: SyncableParameterType.STRING
}
}, },
{ {
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN, 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.', 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, defaultValue: 2500,
type: SettingsFieldType.INPUT, type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL
sync: {
serverKey: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
paramType: SyncableParameterType.NUMBER
}
}, },
{ {
key: SETTINGS_KEYS.SEND_ON_ENTER, 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.', help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
defaultValue: true, defaultValue: true,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL
sync: {
serverKey: SETTINGS_KEYS.SEND_ON_ENTER,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
@@ -149,11 +136,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL,
isExperimental: true, isExperimental: true
sync: {
serverKey: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION, key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
@@ -162,22 +145,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL,
isExperimental: true, isExperimental: true
sync: {
serverKey: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
...TITLE_GENERATION_BASE, ...TITLE_GENERATION_BASE,
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE, key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
label: 'Conversation title', 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.', 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, defaultValue: true
sync: {
serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT, key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
@@ -186,11 +161,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
defaultValue: TITLE_GENERATION.DEFAULT_PROMPT, defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
type: SettingsFieldType.TEXTAREA, type: SettingsFieldType.TEXTAREA,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL,
dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM, dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM
sync: {
serverKey: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
paramType: SyncableParameterType.STRING
}
}, },
{ {
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT, 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.', 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, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL
sync: {
serverKey: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.PDF_AS_IMAGE, 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.', help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL
sync: {
serverKey: SETTINGS_KEYS.PDF_AS_IMAGE,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION, 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.', help: 'Images larger than this will be resized before sending to server. Set to 0 to disable.',
defaultValue: 0, defaultValue: 0,
type: SettingsFieldType.INPUT, type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.GENERAL, section: SETTINGS_SECTION_SLUGS.GENERAL
sync: {
serverKey: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION,
paramType: SyncableParameterType.NUMBER
}
} }
] ]
}, },
@@ -241,11 +200,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.', help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.SHOW_AGENTIC_TURN_STATS, 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.', help: 'Expand thought process by default when generating messages.',
defaultValue: true, defaultValue: true,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT, 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.', help: 'Automatically expand tool call details while executing and keep them expanded after completion.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN, 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.', help: 'Render user messages using markdown formatting in the chat.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN, 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.', help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
defaultValue: true, defaultValue: true,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS, 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.', help: 'Always display code blocks at their full natural height, overriding any height limits.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL, 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.', help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP, 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.', help: 'Always keep the sidebar visible on desktop instead of auto-hiding it.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES, 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.', 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, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.SHOW_MODEL_QUANTIZATION, 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.', help: 'Display quantization badges (e.g. Q8_0, Q4_K_M) next to model names throughout the interface.',
defaultValue: true, defaultValue: true,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.SHOW_MODEL_QUANTIZATION,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.SHOW_MODEL_TAGS, 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.', help: 'Display model tags (e.g. "vision", "reasoning") next to model names throughout the interface.',
defaultValue: true, defaultValue: true,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.SHOW_MODEL_TAGS,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.SHOW_BUILD_VERSION, 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.', help: 'Display the current build version in the bottom-right corner of the interface.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY, section: SETTINGS_SECTION_SLUGS.DISPLAY
sync: {
serverKey: SETTINGS_KEYS.SHOW_BUILD_VERSION,
paramType: SyncableParameterType.BOOLEAN
}
} }
] ]
}, },
@@ -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.', help: 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.SAMPLING, section: SETTINGS_SECTION_SLUGS.SAMPLING
sync: {
serverKey: SETTINGS_KEYS.BACKEND_SAMPLING,
paramType: SyncableParameterType.BOOLEAN
}
} }
] ]
}, },
@@ -638,11 +545,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
defaultValue: 10, defaultValue: 10,
type: SettingsFieldType.INPUT, type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.AGENTIC, section: SETTINGS_SECTION_SLUGS.AGENTIC,
isPositiveInteger: true, isPositiveInteger: true
sync: {
serverKey: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
paramType: SyncableParameterType.NUMBER
}
}, },
{ {
key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS, key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
@@ -651,11 +554,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
type: SettingsFieldType.INPUT, type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.AGENTIC, section: SETTINGS_SECTION_SLUGS.AGENTIC,
isPositiveInteger: true, isPositiveInteger: true
sync: {
serverKey: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
paramType: SyncableParameterType.NUMBER
}
} }
] ]
}, },
@@ -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.', 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, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER, section: SETTINGS_SECTION_SLUGS.DEVELOPER
sync: {
serverKey: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING, 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.', help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER, section: SETTINGS_SECTION_SLUGS.DEVELOPER
sync: {
serverKey: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT, 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.', 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, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER, section: SETTINGS_SECTION_SLUGS.DEVELOPER
sync: {
serverKey: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH, 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', help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER, section: SETTINGS_SECTION_SLUGS.DEVELOPER
sync: {
serverKey: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.JS_SANDBOX_ENABLED, 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.', 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, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER, section: SETTINGS_SECTION_SLUGS.DEVELOPER
sync: {
serverKey: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.SYMBOLIC_MATH_ENABLED, 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.', help: 'CSS injected into the page at runtime. Set it here, or ship it server side via the --ui-config customCss field.',
defaultValue: '', defaultValue: '',
type: SettingsFieldType.TEXTAREA, type: SettingsFieldType.TEXTAREA,
section: SETTINGS_SECTION_SLUGS.DEVELOPER, section: SETTINGS_SECTION_SLUGS.DEVELOPER
sync: {
serverKey: SETTINGS_KEYS.CUSTOM_CSS,
paramType: SyncableParameterType.STRING
}
} }
] ]
} }
@@ -763,30 +638,21 @@ const NON_UI_SETTINGS: SettingsEntry[] = [
label: 'Show system message', label: 'Show system message',
help: 'Display the system message at the top of each conversation.', help: 'Display the system message at the top of each conversation.',
defaultValue: true, defaultValue: true,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX
sync: {
serverKey: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE,
paramType: SyncableParameterType.BOOLEAN
}
}, },
{ {
key: SETTINGS_KEYS.MCP_SERVERS, key: SETTINGS_KEYS.MCP_SERVERS,
label: 'MCP servers', label: 'MCP servers',
help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.', help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
defaultValue: '[]', defaultValue: '[]',
type: SettingsFieldType.INPUT, type: SettingsFieldType.INPUT
sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING }
}, },
{ {
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM, key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
label: 'Generate title with LLM', label: 'Generate title with LLM',
help: 'Counterpart of the conversation title radio; stored and synced without a dedicated UI field.', help: 'Counterpart of the conversation title radio; stored and synced without a dedicated UI field.',
defaultValue: false, defaultValue: false,
type: SettingsFieldType.CHECKBOX, type: SettingsFieldType.CHECKBOX
sync: {
serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
paramType: SyncableParameterType.BOOLEAN
}
} }
// { // {
// key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, // key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED,
@@ -795,7 +661,7 @@ const NON_UI_SETTINGS: SettingsEntry[] = [
// defaultValue: false, // defaultValue: false,
// type: SettingsFieldType.CHECKBOX, // type: SettingsFieldType.CHECKBOX,
// isExperimental: true, // isExperimental: true,
// sync: { serverKey: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, paramType: SyncableParameterType.BOOLEAN } //
// } // }
]; ];
+1
View File
@@ -180,6 +180,7 @@ export enum UriPattern {
// MIME type enums // MIME type enums
export enum MimeTypeApplication { export enum MimeTypeApplication {
JSON = 'application/json',
PDF = 'application/pdf', PDF = 'application/pdf',
OCTET_STREAM = 'application/octet-stream', OCTET_STREAM = 'application/octet-stream',
ZIP = 'application/zip' ZIP = 'application/zip'
@@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ParameterSyncService } from './parameter-sync.service'; import { ParameterSyncService } from './parameter-sync.service';
import { ColorMode } from '$lib/enums';
describe('ParameterSyncService', () => { describe('ParameterSyncService', () => {
describe('roundFloatingPoint', () => { describe('roundFloatingPoint', () => {
@@ -132,18 +131,13 @@ describe('ParameterSyncService', () => {
expect(result.temperature).toBe(0.7); expect(result.temperature).toBe(0.7);
}); });
it('should merge ui settings from props when provided', () => { it('extracts sampling twins only, never ui settings', () => {
const result = ParameterSyncService.extractServerDefaults(null, { const result = ParameterSyncService.extractServerDefaults(null);
pasteLongTextToFileLen: 0,
pdfAsImage: true,
renderUserContentAsMarkdown: false,
theme: ColorMode.DARK
});
expect(result.pasteLongTextToFileLen).toBe(0); expect(result).toEqual({});
expect(result.pdfAsImage).toBe(true); expect(ParameterSyncService.canSyncParameter('theme')).toBe(false);
expect(result.renderUserContentAsMarkdown).toBe(false); expect(ParameterSyncService.canSyncParameter('pdfAsImage')).toBe(false);
expect(result.theme).toBeUndefined(); expect(ParameterSyncService.canSyncParameter('temperature')).toBe(true);
}); });
}); });
}); });
@@ -29,12 +29,10 @@ export class ParameterSyncService {
* Converts samplers array to semicolon-delimited string for UI display. * Converts samplers array to semicolon-delimited string for UI display.
* *
* @param serverParams - Raw generation settings from server `/props` endpoint * @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 * @returns Record of extracted parameter key-value pairs with normalized precision
*/ */
static extractServerDefaults( static extractServerDefaults(
serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null, serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null
uiSettings?: Record<string, string | number | boolean>
): ParameterRecord { ): ParameterRecord {
const extracted: 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; return extracted;
} }
+4 -2
View File
@@ -16,6 +16,8 @@ import { DatabaseService } from '$lib/services/database.service';
import { ChatService } from '$lib/services/chat.service'; import { ChatService } from '$lib/services/chat.service';
import { streamIdentity } from '$lib/utils/stream-identity'; import { streamIdentity } from '$lib/utils/stream-identity';
import { getAuthHeaders } from '$lib/utils/api-headers'; 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 { conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { agenticStore } from '$lib/stores/agentic.svelte'; import { agenticStore } from '$lib/stores/agentic.svelte';
@@ -208,7 +210,7 @@ class ChatStore {
// POST the one conv id we are probing // POST the one conv id we are probing
listResp = await fetch(`./v1/streams/lookup`, { listResp = await fetch(`./v1/streams/lookup`, {
method: 'POST', method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, headers: { ...getAuthHeaders(), [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON },
body: JSON.stringify({ conversation_ids: [convId] }) body: JSON.stringify({ conversation_ids: [convId] })
}); });
} catch (e) { } catch (e) {
@@ -672,7 +674,7 @@ class ChatStore {
try { try {
const resp = await fetch('./v1/streams/lookup', { const resp = await fetch('./v1/streams/lookup', {
method: 'POST', method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, headers: { ...getAuthHeaders(), [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON },
body: JSON.stringify({ conversation_ids: lookupIds }) body: JSON.stringify({ conversation_ids: lookupIds })
}); });
if (!resp.ok) return; if (!resp.ok) return;
+37 -7
View File
@@ -64,6 +64,10 @@ class SettingsStore {
isInitialized = $state(false); isInitialized = $state(false);
userOverrides = $state<Set<string>>(new Set()); 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 * Centralizes the pattern of getting and extracting server defaults
*/ */
private getServerDefaults(): Record<string, string | number | boolean> { private getServerDefaults(): Record<string, string | number | boolean> {
const serverParams = serverStore.defaultParams; return ParameterSyncService.extractServerDefaults(serverStore.defaultParams);
const uiSettings = serverStore.uiSettings;
return ParameterSyncService.extractServerDefaults(serverParams, uiSettings);
} }
constructor() { constructor() {
@@ -121,6 +122,11 @@ class SettingsStore {
try { try {
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY); 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 || '{}'); const savedVal = JSON.parse(storedConfigRaw || '{}');
// Merge with defaults to prevent breaking changes // Merge with defaults to prevent breaking changes
@@ -349,9 +355,12 @@ class SettingsStore {
} }
} }
// UI settings need actual values in config (no placeholder mechanism), // UI settings are the admin's defaults for new users: applied once on
// so write them for non-overridden keys // the first visit, never on later loads, so the user's config can
if (uiSettings) { // 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)) { for (const [key, value] of Object.entries(uiSettings)) {
if (!this.userOverrides.has(key) && value !== undefined) { if (!this.userOverrides.has(key) && value !== undefined) {
setConfigValue(this.config, key, value); setConfigValue(this.config, key, value);
@@ -391,6 +400,27 @@ class SettingsStore {
this.userOverrides.delete(key); 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(); this.saveConfig();
} }
+3 -1
View File
@@ -2,9 +2,11 @@ import { config } from '$lib/stores/settings.svelte';
import { import {
AUTHORIZATION_HEADER, AUTHORIZATION_HEADER,
BEARER_PREFIX, BEARER_PREFIX,
CONTENT_TYPE_HEADER,
CORS_PROXY_HEADER_PREFIX, CORS_PROXY_HEADER_PREFIX,
REDACTED_HEADERS REDACTED_HEADERS
} from '$lib/constants'; } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { redactValue } from './redact'; import { redactValue } from './redact';
/** /**
@@ -23,7 +25,7 @@ export function getAuthHeaders(): Record<string, string> {
*/ */
export function getJsonHeaders(): Record<string, string> { export function getJsonHeaders(): Record<string, string> {
return { return {
'Content-Type': 'application/json', [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON,
...getAuthHeaders() ...getAuthHeaders()
}; };
} }
+10 -10
View File
@@ -1,7 +1,8 @@
import { base } from '$app/paths'; import { base } from '$app/paths';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { browser } from '$app/environment'; 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'; 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; 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 { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey}`
}; };
// 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 }); const response = await fetch(`${base}/props`, { headers });
if (!response.ok) { if (!response.ok) {
+3 -2
View File
@@ -99,8 +99,9 @@
function checkApiKey() { function checkApiKey() {
const apiKey = config().apiKey; const apiKey = config().apiKey;
// No API key configured — server doesn't require auth, no need to validate. // Without a stored key there is nothing to re-validate here; the keyless
// This mirrors the early return in validateApiKey() to avoid redundant /props requests. // 401 case is handled by validateApiKey() at navigation time, and the
// reload below must never fire in a keyless loop.
if (!apiKey || apiKey.trim() === '') { if (!apiKey || apiKey.trim() === '') {
return; return;
} }
@@ -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');
});
});
@@ -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]);
}
}
});
});
@@ -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);
});
});