webui: add custom CSS injection via config (#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>
This commit is contained in:
Pascal
2026-05-30 23:49:31 +02:00
committed by GitHub
parent aa46bda89b
commit d749821db3
8 changed files with 65 additions and 9 deletions
@@ -75,9 +75,13 @@
}
function handleSave() {
if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
if (
localConfig.customJson &&
typeof localConfig.customJson === 'string' &&
localConfig.customJson.trim()
) {
try {
JSON.parse(localConfig.custom);
JSON.parse(localConfig.customJson);
} catch (error) {
alert('Invalid JSON in custom parameters. Please check the format and try again.');
console.error(error);
+2 -1
View File
@@ -66,5 +66,6 @@ export const SETTINGS_KEYS = {
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
CUSTOM: 'custom'
CUSTOM_JSON: 'customJson',
CUSTOM_CSS: 'customCss'
} as const;
@@ -659,12 +659,24 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
}
},
{
key: SETTINGS_KEYS.CUSTOM,
key: SETTINGS_KEYS.CUSTOM_JSON,
label: 'Custom JSON',
help: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
defaultValue: '',
type: SettingsFieldType.TEXTAREA,
section: SETTINGS_SECTION_SLUGS.DEVELOPER
},
{
key: SETTINGS_KEYS.CUSTOM_CSS,
label: 'Custom CSS',
help: 'CSS injected into the page at runtime. Set it here, or ship it server side via the --ui-config customCss field.',
defaultValue: '',
type: SettingsFieldType.TEXTAREA,
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
sync: {
serverKey: SETTINGS_KEYS.CUSTOM_CSS,
paramType: SyncableParameterType.STRING
}
}
]
},
+26 -1
View File
@@ -470,11 +470,36 @@ const themeMigration: Migration = {
// Migration Registry & Runner
const CUSTOM_JSON_MIGRATION_ID = 'custom-json-key-v1';
const customJsonKeyMigration: Migration = {
id: CUSTOM_JSON_MIGRATION_ID,
description: 'Copy legacy custom config key to customJson (non-destructive)',
async run(): Promise<void> {
const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
if (configRaw === null) return;
const config = JSON.parse(configRaw);
if (!('custom' in config)) return;
if (SETTINGS_KEYS.CUSTOM_JSON in config) return;
config[SETTINGS_KEYS.CUSTOM_JSON] = config.custom;
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
// Non-destructive: keep the legacy custom key for downgrade compatibility
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] Custom JSON: copied custom to customJson (preserved old key)`);
}
};
const migrations: Migration[] = [
localStorageMigration,
idxdbMigration,
legacyMessageMigration,
themeMigration
themeMigration,
customJsonKeyMigration
];
export const MigrationService = {
+1 -1
View File
@@ -1869,7 +1869,7 @@ class ChatStore {
apiOptions.backend_sampling = currentConfig.backend_sampling;
if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
if (currentConfig.customJson) apiOptions.custom = currentConfig.customJson;
return apiOptions;
}
+1 -1
View File
@@ -57,7 +57,7 @@ class ToolsStore {
}
get customTools(): OpenAIToolDefinition[] {
const raw = config().custom;
const raw = config().customJson;
if (!raw || typeof raw !== 'string') return [];
try {
+2 -2
View File
@@ -90,8 +90,8 @@ export interface SettingsChatServiceOptions {
// Sampler configuration
samplers?: string | string[];
backend_sampling?: boolean;
// Custom parameters
custom?: string;
// Custom JSON parameters
customJson?: string;
timings_per_token?: boolean;
// Continuation control (vLLM compat), opt in to the explicit continue final message flag
continueFinalMessage?: boolean;
+14
View File
@@ -169,6 +169,14 @@
}
});
// Inject custom CSS at runtime through an action on the head style node
// textContent keeps the value as text, never parsed as HTML
function customCss(node: HTMLStyleElement) {
$effect(() => {
node.textContent = (config().customCss as string | undefined) ?? '';
});
}
// Fetch router models when in router mode (for status and modalities)
// Wait for models to be loaded first, run only once
let routerModelsFetched = false;
@@ -227,6 +235,12 @@
});
</script>
<svelte:head>
{#if config().customCss}
<style use:customCss></style>
{/if}
</svelte:head>
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
<ModeWatcher />
<Toaster richColors />