ui: read persisted settings before the API key probe (#27365)
The route loads run ahead of the root layout script, so validateApiKey read the settings store while it still held factory defaults and probed /props without the stored key. initStores() now hands the same startup promise to every caller and the chat loads await it before probing. The one-time admin baseline no longer overwrites a key the user has already set: on a first visit the config carries factory values only, so a diverging key comes from the user and wins.
This commit is contained in:
@@ -1,10 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Explicit store initialization, called once from the root layout.
|
* Explicit store initialization, run once and shared by every caller.
|
||||||
*
|
*
|
||||||
* Order matters: migrations run first because they rename and rewrite
|
* Order matters: migrations run first because they rename and rewrite
|
||||||
* localStorage keys, so every store that reads localStorage initializes
|
* localStorage keys, so every store that reads localStorage initializes
|
||||||
* only after they complete. Constructors and module-level side effects
|
* only after they complete. Constructors and module-level side effects
|
||||||
* stay empty so import order can no longer change startup behavior.
|
* stay empty so import order can no longer change startup behavior.
|
||||||
|
*
|
||||||
|
* The returned promise resolves once the persisted state is in memory, which
|
||||||
|
* route loads await before reading settings: they run ahead of the root layout
|
||||||
|
* script. The conversation list loads in the background, awaited by the chat
|
||||||
|
* page that renders it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// direct imports, not via the barrel, to avoid circular deps
|
// direct imports, not via the barrel, to avoid circular deps
|
||||||
@@ -16,19 +21,20 @@ import { versionStore } from './version.svelte';
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { MigrationService } from '$lib/services/migration.service';
|
import { MigrationService } from '$lib/services/migration.service';
|
||||||
|
|
||||||
let started = false;
|
let startup: Promise<void> | null = null;
|
||||||
|
|
||||||
export async function initStores(): Promise<void> {
|
export function initStores(): Promise<void> {
|
||||||
if (!browser || started) return;
|
if (!browser) return Promise.resolve();
|
||||||
|
|
||||||
started = true;
|
startup ??= (async () => {
|
||||||
|
await MigrationService.runAllMigrations();
|
||||||
|
|
||||||
await MigrationService.runAllMigrations();
|
settingsStore.initialize();
|
||||||
|
permissionsStore.initialize();
|
||||||
|
toolsStore.initialize();
|
||||||
|
void versionStore.initialize();
|
||||||
|
void conversationsStore.init();
|
||||||
|
})();
|
||||||
|
|
||||||
settingsStore.initialize();
|
return startup;
|
||||||
permissionsStore.initialize();
|
|
||||||
toolsStore.initialize();
|
|
||||||
void versionStore.initialize();
|
|
||||||
|
|
||||||
await conversationsStore.init();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,17 +358,24 @@ class SettingsStore {
|
|||||||
// UI settings are the admin's defaults for new users: applied once on
|
// 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
|
// 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.
|
// diverge. "Reset to Default" is the explicit way back to the baseline.
|
||||||
|
// A first visit config carries factory values only, so a key that
|
||||||
|
// already diverges here was set by the user before the baseline could
|
||||||
|
// be reached, through the API key splash, and stays theirs.
|
||||||
if (uiSettings && this.isFirstVisit) {
|
if (uiSettings && this.isFirstVisit) {
|
||||||
this.isFirstVisit = false;
|
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 (value === undefined || this.userOverrides.has(key)) continue;
|
||||||
setConfigValue(this.config, key, value);
|
|
||||||
|
|
||||||
// theme lives in mode-watcher, not just in config -> propagate
|
if (getConfigValue(this.config, key) !== getConfigValue(SETTING_CONFIG_DEFAULT, key)) {
|
||||||
if (key === SETTINGS_KEYS.THEME) {
|
continue;
|
||||||
setMode(value as ColorMode);
|
}
|
||||||
}
|
|
||||||
|
setConfigValue(this.config, key, value);
|
||||||
|
|
||||||
|
// theme lives in mode-watcher, not just in config -> propagate
|
||||||
|
if (key === SETTINGS_KEYS.THEME) {
|
||||||
|
setMode(value as ColorMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
import { initStores } from '$lib/stores/init';
|
||||||
import { validateApiKey } from '$lib/utils';
|
import { validateApiKey } from '$lib/utils';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch }) => {
|
export const load: PageLoad = async ({ fetch }) => {
|
||||||
|
// loads run before the root layout script, so the stored API key reaches
|
||||||
|
// the probe only once the settings store has read localStorage
|
||||||
|
await initStores();
|
||||||
await validateApiKey(fetch);
|
await validateApiKey(fetch);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
import { initStores } from '$lib/stores/init';
|
||||||
import { validateApiKey } from '$lib/utils';
|
import { validateApiKey } from '$lib/utils';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch }) => {
|
export const load: PageLoad = async ({ fetch }) => {
|
||||||
|
// loads run before the root layout script, so the stored API key reaches
|
||||||
|
// the probe only once the settings store has read localStorage
|
||||||
|
await initStores();
|
||||||
await validateApiKey(fetch);
|
await validateApiKey(fetch);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ describe('server ui_settings application semantics', () => {
|
|||||||
expect(stored.apiKey).toBe('sk-user-key');
|
expect(stored.apiKey).toBe('sk-user-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a value the user sets before the baseline is reachable', () => {
|
||||||
|
settingsStore.initialize();
|
||||||
|
// the splash is the only way in when the server runs with --api-key,
|
||||||
|
// so the first user write lands before the first successful /props
|
||||||
|
settingsStore.updateConfig('apiKey', 'sk-user-key');
|
||||||
|
mockProps({ apiKey: 'admin-placeholder', theme: 'dark' });
|
||||||
|
|
||||||
|
settingsStore.syncWithServerDefaults();
|
||||||
|
|
||||||
|
expect(settingsStore.config.apiKey).toBe('sk-user-key');
|
||||||
|
expect(settingsStore.config.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
it('Reset to Default reapplies the full baseline, api key included', () => {
|
it('Reset to Default reapplies the full baseline, api key included', () => {
|
||||||
settingsStore.initialize();
|
settingsStore.initialize();
|
||||||
settingsStore.updateConfig('theme', 'light');
|
settingsStore.updateConfig('theme', 'light');
|
||||||
|
|||||||
Reference in New Issue
Block a user