UI: Fix settings precedence, Factory < Admin (--ui-config-file) < Users (Settings panel) (#26002)
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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 }
|
//
|
||||||
// }
|
// }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user