Skip to content

Commit d749821

Browse files
webui: add custom CSS injection via config (ggml-org#23904)
* webui: add custom CSS injection via config register a customCSS setting in the Developer section under Custom JSON, syncable so it rides the existing ui-config pass through. inject the value into a single style element in the head, reactive on the setting. lets an operator theme a prebuilt binary through --ui-config without rebuilding, and lets a user set it from the settings panel. * ui: address review from @niutech and @allozaur, rename custom JSON key and CSS field * ui: address review from @allozaur, move custom CSS injection to a style tag in svelte:head * ui: inject custom CSS through a svelte action instead of a bound element move the textContent write into a use: action on the head style node. the action is the idiomatic way to touch a node, so the no-dom-manipulating lint rule is satisfied without a disable. value stays text through textContent, never parsed as HTML. * Update tools/ui/src/lib/constants/settings-keys.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * ui: address review from @allozaur, rename custom config key to customJson with migration rename the custom config key to customJson across the type, the chat request builder, the settings save check and the custom tools reader, keeping the custom API param name unchanged. add a non destructive migration that copies the legacy custom key to customJson at startup. only render the head style tag when custom CSS is set. --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
1 parent aa46bda commit d749821

8 files changed

Lines changed: 65 additions & 9 deletions

File tree

tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,13 @@
7575
}
7676
7777
function handleSave() {
78-
if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
78+
if (
79+
localConfig.customJson &&
80+
typeof localConfig.customJson === 'string' &&
81+
localConfig.customJson.trim()
82+
) {
7983
try {
80-
JSON.parse(localConfig.custom);
84+
JSON.parse(localConfig.customJson);
8185
} catch (error) {
8286
alert('Invalid JSON in custom parameters. Please check the format and try again.');
8387
console.error(error);

tools/ui/src/lib/constants/settings-keys.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,6 @@ export const SETTINGS_KEYS = {
6666
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
6767
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
6868
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
69-
CUSTOM: 'custom'
69+
CUSTOM_JSON: 'customJson',
70+
CUSTOM_CSS: 'customCss'
7071
} as const;

tools/ui/src/lib/constants/settings-registry.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,12 +659,24 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
659659
}
660660
},
661661
{
662-
key: SETTINGS_KEYS.CUSTOM,
662+
key: SETTINGS_KEYS.CUSTOM_JSON,
663663
label: 'Custom JSON',
664664
help: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
665665
defaultValue: '',
666666
type: SettingsFieldType.TEXTAREA,
667667
section: SETTINGS_SECTION_SLUGS.DEVELOPER
668+
},
669+
{
670+
key: SETTINGS_KEYS.CUSTOM_CSS,
671+
label: 'Custom CSS',
672+
help: 'CSS injected into the page at runtime. Set it here, or ship it server side via the --ui-config customCss field.',
673+
defaultValue: '',
674+
type: SettingsFieldType.TEXTAREA,
675+
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
676+
sync: {
677+
serverKey: SETTINGS_KEYS.CUSTOM_CSS,
678+
paramType: SyncableParameterType.STRING
679+
}
668680
}
669681
]
670682
},

tools/ui/src/lib/services/migration.service.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,11 +470,36 @@ const themeMigration: Migration = {
470470

471471
// Migration Registry & Runner
472472

473+
const CUSTOM_JSON_MIGRATION_ID = 'custom-json-key-v1';
474+
475+
const customJsonKeyMigration: Migration = {
476+
id: CUSTOM_JSON_MIGRATION_ID,
477+
description: 'Copy legacy custom config key to customJson (non-destructive)',
478+
479+
async run(): Promise<void> {
480+
const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
481+
if (configRaw === null) return;
482+
483+
const config = JSON.parse(configRaw);
484+
485+
if (!('custom' in config)) return;
486+
if (SETTINGS_KEYS.CUSTOM_JSON in config) return;
487+
488+
config[SETTINGS_KEYS.CUSTOM_JSON] = config.custom;
489+
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
490+
491+
// Non-destructive: keep the legacy custom key for downgrade compatibility
492+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
493+
console.log(`[Migration] Custom JSON: copied custom to customJson (preserved old key)`);
494+
}
495+
};
496+
473497
const migrations: Migration[] = [
474498
localStorageMigration,
475499
idxdbMigration,
476500
legacyMessageMigration,
477-
themeMigration
501+
themeMigration,
502+
customJsonKeyMigration
478503
];
479504

480505
export const MigrationService = {

tools/ui/src/lib/stores/chat.svelte.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1869,7 +1869,7 @@ class ChatStore {
18691869

18701870
apiOptions.backend_sampling = currentConfig.backend_sampling;
18711871

1872-
if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
1872+
if (currentConfig.customJson) apiOptions.custom = currentConfig.customJson;
18731873

18741874
return apiOptions;
18751875
}

tools/ui/src/lib/stores/tools.svelte.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class ToolsStore {
5757
}
5858

5959
get customTools(): OpenAIToolDefinition[] {
60-
const raw = config().custom;
60+
const raw = config().customJson;
6161
if (!raw || typeof raw !== 'string') return [];
6262

6363
try {

tools/ui/src/lib/types/settings.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ export interface SettingsChatServiceOptions {
9090
// Sampler configuration
9191
samplers?: string | string[];
9292
backend_sampling?: boolean;
93-
// Custom parameters
94-
custom?: string;
93+
// Custom JSON parameters
94+
customJson?: string;
9595
timings_per_token?: boolean;
9696
// Continuation control (vLLM compat), opt in to the explicit continue final message flag
9797
continueFinalMessage?: boolean;

tools/ui/src/routes/+layout.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@
169169
}
170170
});
171171
172+
// Inject custom CSS at runtime through an action on the head style node
173+
// textContent keeps the value as text, never parsed as HTML
174+
function customCss(node: HTMLStyleElement) {
175+
$effect(() => {
176+
node.textContent = (config().customCss as string | undefined) ?? '';
177+
});
178+
}
179+
172180
// Fetch router models when in router mode (for status and modalities)
173181
// Wait for models to be loaded first, run only once
174182
let routerModelsFetched = false;
@@ -227,6 +235,12 @@
227235
});
228236
</script>
229237

238+
<svelte:head>
239+
{#if config().customCss}
240+
<style use:customCss></style>
241+
{/if}
242+
</svelte:head>
243+
230244
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
231245
<ModeWatcher />
232246
<Toaster richColors />

0 commit comments

Comments
 (0)