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) */
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',
+36 -170
View 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 }
//
// }
];
+1
View 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'
@@ -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);
});
});
});
@@ -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;
}
+4 -2
View 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;
+37 -7
View 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();
}
+3 -1
View 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()
};
}
+10 -10
View 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) {
+3 -2
View 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;
}
@@ -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);
});
});