Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 36 additions & 170 deletions tools/ui/src/lib/constants/settings-registry.ts

Large diffs are not rendered by default.

18 changes: 6 additions & 12 deletions tools/ui/src/lib/services/parameter-sync.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
16 changes: 1 addition & 15 deletions tools/ui/src/lib/services/parameter-sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand All @@ -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;
}

Expand Down
44 changes: 37 additions & 7 deletions tools/ui/src/lib/stores/settings.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
*
*
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}

Expand Down
17 changes: 8 additions & 9 deletions tools/ui/src/lib/utils/api-key-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,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': 'application/json'
Comment thread
allozaur marked this conversation as resolved.
Outdated
};

// 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) {
Expand Down
5 changes: 3 additions & 2 deletions tools/ui/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
41 changes: 41 additions & 0 deletions tools/ui/tests/client/apikey-splash.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
107 changes: 107 additions & 0 deletions tools/ui/tests/client/settings-registry-invariants.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
}
});
});
Loading
Loading