ui: Settings navigation cleanup (#27241)

* ui : rework the settings registry into ordered raw-data sections

SETTINGS_REGISTRY becomes an ordered SettingsSectionEntry[] array; the
array order is the sidebar display order. Section titles, color mode
options and title radio options are declared inline in their section or
entry. Entries gain showInUi; MCP servers, the system-message toggle and
the title LLM flag become hidden entries of their own section.

Derived values (config defaults, help info, chat sections, numeric field
lists, syncable parameters) are still derived here; they move to their
actual consumers in follow-up commits.

* ui : extract settings localStorage persistence into SettingsService

Stateless load/save of the settings config and user-override keys, plus the
legacy theme key migration. Business logic (default merging, mobile
sendOnEnter default, applying the migrated theme) stays in the store.

* ui : move the settings exit route into ROUTES

SETTINGS_FALLBACK_EXIT_ROUTE is just a route, so it lives with the other
routes as ROUTES.SETTINGS_EXIT.

* ui : derive the syncable parameter list in the parameter sync service

The syncable parameter mapping is only consumed by the sync service, so
derive it there from the registry instead of exporting it from the
constants file.

* ui : restore isPrivate for API key masking

* ui : clean up settings registry and router fetch guard

Drop the per-entry section field (duplicates the parent slug and is
never read) and guard the router model fetch on fields?.length so the
Tools/Import-Export pages with empty fields are excluded again.

Assisted-by: pi

* ui : merge sampling and penalties settings into one section

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-08-21 12:30:03 +02:00
committed by GitHub
parent e467c2ff61
commit 5b6ddc9675
11 changed files with 610 additions and 611 deletions
@@ -15,7 +15,7 @@
NUMERIC_FIELDS,
POSITIVE_INTEGER_FIELDS,
SETTINGS_CHAT_SECTIONS,
SETTINGS_SECTION_TITLES
SETTINGS_SECTION_SLUGS
} from '$lib/constants';
import { ColorMode } from '$lib/enums/ui.enums';
import { RouterService } from '$lib/services/router.service';
@@ -46,7 +46,7 @@
let fetchInitiated = false;
$effect(() => {
if (serverStore.isRouterMode && currentSection.fields && !fetchInitiated) {
if (serverStore.isRouterMode && currentSection.fields?.length && !fetchInitiated) {
fetchInitiated = true;
void modelsStore
@@ -148,9 +148,9 @@
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
{#if currentSection.title === SETTINGS_SECTION_TITLES.TOOLS}
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.TOOLS}
<SettingsChatToolsTab />
{:else if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.IMPORT_EXPORT}
<SettingsChatImportExportTab />
{:else if currentSection.fields}
<div class="space-y-6">
@@ -161,7 +161,7 @@
onThemeChange={handleThemeChange}
/>
{#if currentSection.title === SETTINGS_SECTION_TITLES.GENERAL}
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.GENERAL}
<div class="flex justify-end">
<Button variant="outline" onclick={() => window.location.reload()}>
<RefreshCw class="h-3 w-3" />
+1 -1
View File
@@ -48,7 +48,7 @@ export * from './pwa.constants';
export * from './routes.constants';
export * from './sandbox.constants';
export * from './settings-keys.constants';
export * from './settings-registry.constants';
export * from './settings.constants';
export * from './special-characters.constants';
export * from './stream.constants';
export * from './supported-file-types.constants';
+2 -12
View File
@@ -10,18 +10,6 @@ export const URL_PARAMS = {
QUERY: 'q'
} as const;
/** Settings section slugs — used for routes and navigation. */
export const SETTINGS_SECTION_SLUGS = {
AGENTIC: 'agentic',
DEVELOPER: 'developer',
DISPLAY: 'display',
GENERAL: 'general',
IMPORT_EXPORT: 'import-export',
PENALTIES: 'penalties',
SAMPLING: 'sampling',
TOOLS: 'tools'
} as const;
export const ROUTES = {
/** Chat base — for dynamic chat URLs use RouterService. */
CHAT: '#/chat',
@@ -33,6 +21,8 @@ export const ROUTES = {
SEARCH: '#/search',
/** Settings base — for dynamic settings URLs use RouterService. */
SETTINGS: '#/settings',
/** Exit destination for the settings view (fallback when no referrer). */
SETTINGS_EXIT: '#/',
/** Root — start of the app. */
START: '#/'
} as const;
+10
View File
@@ -340,3 +340,13 @@ export { RouterService } from './router.service';
* @see migration.service.ts — full implementation (non-destructive)
*/
export { MigrationService } from './migration.service';
/**
* **SettingsService** - localStorage persistence layer for settings
*
* Stateless read/write of the settings config and user-override keys. Business
* logic (default merging, mobile defaults, theme migration) stays in the store.
*
* @see settingsStore in stores/settings/index.svelte.ts - reactive state + business logic
*/
export { SettingsService } from './settings.service';
@@ -6,11 +6,23 @@
* No reactive state; consumed by settingsStore.
*/
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
import { SETTINGS_KEYS, SETTINGS_REGISTRY } from '$lib/constants';
import { ParameterSource, SyncableParameterType } from '$lib/enums';
import type { ParameterInfo, ParameterRecord, ParameterValue } from '$lib/types';
import type { ParameterInfo, ParameterRecord, ParameterValue, SyncableParameter } from '$lib/types';
import { normalizeFloatingPoint } from '$lib/utils';
/** Mapping of UI setting keys to server parameter keys, derived from the registry. */
export const SYNCABLE_PARAMETERS: SyncableParameter[] = SETTINGS_REGISTRY.flatMap(
(section) => section.settings
)
.filter((s) => s.sync !== undefined)
.map((s) => ({
canSync: true,
key: s.key,
serverKey: s.sync!.serverKey,
type: s.sync!.paramType
}));
export class ParameterSyncService {
/**
* Check if a parameter can be synced from server.
@@ -0,0 +1,76 @@
import { browser } from '$app/environment';
import { CONFIG_LOCALSTORAGE_KEY, USER_OVERRIDES_LOCALSTORAGE_KEY } from '$lib/constants';
/**
* SettingsService - localStorage persistence layer for settings
*
* Stateless read/write of the settings config and user-override keys. Business
* logic (default merging, mobile defaults, theme migration) stays in the store.
*
* **Architecture & Relationships:**
* - **settingsStore**: Primary consumer - loads config on init and persists on change
*
* @see settingsStore in stores/settings/index.svelte.ts - reactive state + business logic
*/
export class SettingsService {
/**
* Read the raw config and user overrides from localStorage.
* @returns Parsed values, or empty defaults when nothing is stored or parsing fails.
*/
static loadConfig(): {
config: Record<string, unknown>;
userOverrides: string[];
isFirstVisit: boolean;
} {
if (!browser) {
return { config: {}, isFirstVisit: false, userOverrides: [] };
}
try {
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
const isFirstVisit = storedConfigRaw === null;
const config = JSON.parse(storedConfigRaw || '{}') as Record<string, unknown>;
const userOverrides = JSON.parse(
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
) as string[];
return { config, isFirstVisit, userOverrides };
} catch (error) {
console.warn('Failed to parse config from localStorage, using defaults:', error);
return { config: {}, isFirstVisit: false, userOverrides: [] };
}
}
/**
* Migrate the legacy un-namespaced "theme" localStorage key.
* Returns the legacy theme value (and removes the key) when present, else null.
*/
static migrateLegacyTheme(): string | null {
if (!browser) return null;
const legacyTheme = localStorage.getItem('theme');
if (legacyTheme) {
localStorage.removeItem('theme');
return legacyTheme;
}
return null;
}
/**
* Persist the config and user overrides to localStorage.
*/
static saveConfig(config: Record<string, unknown>, userOverrides: string[]): void {
if (!browser) return;
try {
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
localStorage.setItem(USER_OVERRIDES_LOCALSTORAGE_KEY, JSON.stringify(userOverrides));
} catch (error) {
console.error('Failed to save config to localStorage:', error);
}
}
}
@@ -8,14 +8,10 @@
*/
import { browser } from '$app/environment';
import {
CONFIG_LOCALSTORAGE_KEY,
SETTING_CONFIG_DEFAULT,
SETTINGS_KEYS,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants';
import { SETTING_CONFIG_DEFAULT, SETTINGS_KEYS } from '$lib/constants';
import { ColorMode } from '$lib/enums';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { SettingsService } from '$lib/services/settings.service';
import { deviceStore } from '$lib/stores/device.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
@@ -428,45 +424,37 @@ class SettingsStore {
}
/**
* Load configuration from localStorage
* Returns default values for missing keys to prevent breaking changes
* Load configuration from localStorage via the persistence service.
* Returns default values for missing keys to prevent breaking changes.
*/
private loadConfig() {
if (!browser) return;
try {
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
const {
config: savedVal,
isFirstVisit,
userOverrides: savedOverrides
} = SettingsService.loadConfig();
// 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;
// First visit: no stored config yet. Server ui_settings apply once in
// this state, then the user's config diverges freely.
this.isFirstVisit = isFirstVisit;
const savedVal = JSON.parse(storedConfigRaw || '{}');
// Merge with defaults to prevent breaking changes
this.config = {
...SETTING_CONFIG_DEFAULT,
...savedVal
};
// Merge with defaults to prevent breaking changes
this.config = {
...SETTING_CONFIG_DEFAULT,
...savedVal
};
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (deviceStore.isMobile) {
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
}
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (deviceStore.isMobile) {
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
}
// Load user overrides
const savedOverrides = JSON.parse(
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
);
this.userOverrides = new Set(savedOverrides);
} catch (error) {
console.warn('Failed to parse config from localStorage, using defaults:', error);
this.config = { ...SETTING_CONFIG_DEFAULT };
this.userOverrides = new Set();
}
// Load user overrides
this.userOverrides = new Set(savedOverrides);
}
/**
@@ -478,32 +466,22 @@ class SettingsStore {
private migrateLegacyTheme() {
if (!browser) return;
const legacyTheme = localStorage.getItem('theme');
const legacyTheme = SettingsService.migrateLegacyTheme();
if (legacyTheme) {
this.config[SETTINGS_KEYS.THEME] = legacyTheme;
localStorage.removeItem('theme');
this.saveConfig();
setMode(legacyTheme as ColorMode);
}
}
/**
* Save the current configuration to localStorage
* Save the current configuration to localStorage via the persistence service.
*/
private saveConfig() {
if (!browser) return;
try {
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config));
localStorage.setItem(
USER_OVERRIDES_LOCALSTORAGE_KEY,
JSON.stringify(Array.from(this.userOverrides))
);
} catch (error) {
console.error('Failed to save config to localStorage:', error);
}
SettingsService.saveConfig(this.config, Array.from(this.userOverrides));
}
}
@@ -5,9 +5,9 @@
* there after a fallback exit. Standalone reactive value, no host.
*/
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
import { ROUTES } from '$lib/constants';
let _url = $state<string>(SETTINGS_FALLBACK_EXIT_ROUTE);
let _url = $state<string>(ROUTES.SETTINGS_EXIT);
export const settingsReferrer = {
get url() {
+3 -1
View File
@@ -25,13 +25,15 @@ export interface SettingsEntry {
help: string;
defaultValue: SettingsConfigValue;
type: SettingsFieldType;
section?: string;
options?: Array<{ value: string; label: string; icon: Component }>;
/** Options rendered for RADIO fields. Each entry maps a `value` (the radio's selected value) to the underlying config `key` whose boolean state mirrors it. */
radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
isExperimental?: boolean;
isPositiveInteger?: boolean;
/** When true, the field is rendered as a password input (e.g. API key). */
isPrivate?: boolean;
/** When false, the setting is stored/synced but has no standalone field; it is rendered by a sibling control or a dedicated page. */
standaloneField?: boolean;
placeholder?: string;
min?: number;
max?: number;
+2 -2
View File
@@ -4,7 +4,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon } from '$lib/components/app';
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
import { ROUTES } from '$lib/constants';
let { children } = $props();
@@ -24,7 +24,7 @@
if (browser && window.history.length > 1 && !prevIsSettings) {
history.back();
} else {
goto(SETTINGS_FALLBACK_EXIT_ROUTE);
goto(ROUTES.SETTINGS_EXIT);
}
}
</script>