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:
@@ -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" />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
+468
-537
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
|
||||
Vendored
+3
-1
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user