server: (webui) add --webui-config (#18028)

* server/webui: add server-side WebUI config support

Add CLI arguments --webui-config (inline JSON) and --webui-config-file
(file path) to configure WebUI default settings from server side.

Backend changes:
- Parse JSON once in server_context::load_model() for performance
- Cache parsed config in webui_settings member (zero overhead on /props)
- Add proper error handling in router mode with try/catch
- Expose webui_settings in /props endpoint for both router and child modes

Frontend changes:
- Add 14 configurable WebUI settings via parameter sync
- Add tests for webui settings extraction
- Fix subpath support with base path in API calls

Addresses feedback from @ngxson and @ggerganov

* server: address review feedback from ngxson

* server: regenerate README with llama-gen-docs
This commit is contained in:
Pascal
2025-12-17 21:45:45 +01:00
committed by GitHub
parent e85e9d7637
commit 6ce3d85796
15 changed files with 163 additions and 27 deletions
@@ -1,4 +1,5 @@
<script lang="ts">
import { base } from '$app/paths';
import { AlertTriangle, RefreshCw, Key, CheckCircle, XCircle } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
@@ -64,7 +65,7 @@
settingsStore.updateConfig('apiKey', apiKeyInput.trim());
// Test the API key by making a real request to the server
const response = await fetch('./props', {
const response = await fetch(`${base}/props`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKeyInput.trim()}`
@@ -130,5 +130,19 @@ describe('ParameterSyncService', () => {
expect(result.max_tokens).toBe(-1);
expect(result.temperature).toBe(0.7);
});
it('should merge webui settings from props when provided', () => {
const result = ParameterSyncService.extractServerDefaults(null, {
pasteLongTextToFileLen: 0,
pdfAsImage: true,
renderUserContentAsMarkdown: false,
theme: 'dark'
});
expect(result.pasteLongTextToFileLen).toBe(0);
expect(result.pdfAsImage).toBe(true);
expect(result.renderUserContentAsMarkdown).toBe(false);
expect(result.theme).toBeUndefined();
});
});
});
@@ -55,7 +55,55 @@ export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
{ key: 'dry_allowed_length', serverKey: 'dry_allowed_length', type: 'number', canSync: true },
{ key: 'dry_penalty_last_n', serverKey: 'dry_penalty_last_n', type: 'number', canSync: true },
{ key: 'max_tokens', serverKey: 'max_tokens', type: 'number', canSync: true },
{ key: 'samplers', serverKey: 'samplers', type: 'string', canSync: true }
{ key: 'samplers', serverKey: 'samplers', type: 'string', canSync: true },
{
key: 'pasteLongTextToFileLen',
serverKey: 'pasteLongTextToFileLen',
type: 'number',
canSync: true
},
{ key: 'pdfAsImage', serverKey: 'pdfAsImage', type: 'boolean', canSync: true },
{
key: 'showThoughtInProgress',
serverKey: 'showThoughtInProgress',
type: 'boolean',
canSync: true
},
{ key: 'showToolCalls', serverKey: 'showToolCalls', type: 'boolean', canSync: true },
{
key: 'disableReasoningFormat',
serverKey: 'disableReasoningFormat',
type: 'boolean',
canSync: true
},
{ key: 'keepStatsVisible', serverKey: 'keepStatsVisible', type: 'boolean', canSync: true },
{ key: 'showMessageStats', serverKey: 'showMessageStats', type: 'boolean', canSync: true },
{
key: 'askForTitleConfirmation',
serverKey: 'askForTitleConfirmation',
type: 'boolean',
canSync: true
},
{ key: 'disableAutoScroll', serverKey: 'disableAutoScroll', type: 'boolean', canSync: true },
{
key: 'renderUserContentAsMarkdown',
serverKey: 'renderUserContentAsMarkdown',
type: 'boolean',
canSync: true
},
{ key: 'autoMicOnEmpty', serverKey: 'autoMicOnEmpty', type: 'boolean', canSync: true },
{
key: 'pyInterpreterEnabled',
serverKey: 'pyInterpreterEnabled',
type: 'boolean',
canSync: true
},
{
key: 'enableContinueGeneration',
serverKey: 'enableContinueGeneration',
type: 'boolean',
canSync: true
}
];
export class ParameterSyncService {
@@ -74,25 +122,39 @@ export class ParameterSyncService {
* Extract server default parameters that can be synced
*/
static extractServerDefaults(
serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null
serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
webuiSettings?: Record<string, string | number | boolean>
): ParameterRecord {
if (!serverParams) return {};
const extracted: ParameterRecord = {};
for (const param of SYNCABLE_PARAMETERS) {
if (param.canSync && param.serverKey in serverParams) {
const value = (serverParams as unknown as Record<string, ParameterValue>)[param.serverKey];
if (value !== undefined) {
// Apply precision rounding to avoid JavaScript floating-point issues
extracted[param.key] = this.roundFloatingPoint(value);
if (serverParams) {
for (const param of SYNCABLE_PARAMETERS) {
if (param.canSync && param.serverKey in serverParams) {
const value = (serverParams as unknown as Record<string, ParameterValue>)[
param.serverKey
];
if (value !== undefined) {
// Apply precision rounding to avoid JavaScript floating-point issues
extracted[param.key] = this.roundFloatingPoint(value);
}
}
}
// Handle samplers array conversion to string
if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
extracted.samplers = serverParams.samplers.join(';');
}
}
// Handle samplers array conversion to string
if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
extracted.samplers = serverParams.samplers.join(';');
if (webuiSettings) {
for (const param of SYNCABLE_PARAMETERS) {
if (param.canSync && param.serverKey in webuiSettings) {
const value = webuiSettings[param.serverKey];
if (value !== undefined) {
extracted[param.key] = this.roundFloatingPoint(value);
}
}
}
}
return extracted;
@@ -40,6 +40,10 @@ class ServerStore {
return this.props?.default_generation_settings?.n_ctx ?? null;
}
get webuiSettings(): Record<string, string | number | boolean> | undefined {
return this.props?.webui_settings;
}
get isRouterMode(): boolean {
return this.role === ServerRole.ROUTER;
}
@@ -66,7 +66,8 @@ class SettingsStore {
*/
private getServerDefaults(): Record<string, string | number | boolean> {
const serverParams = serverStore.defaultParams;
return serverParams ? ParameterSyncService.extractServerDefaults(serverParams) : {};
const webuiSettings = serverStore.webuiSettings;
return ParameterSyncService.extractServerDefaults(serverParams, webuiSettings);
}
constructor() {
+1
View File
@@ -176,6 +176,7 @@ export interface ApiLlamaCppServerProps {
bos_token: string;
eos_token: string;
build_info: string;
webui_settings?: Record<string, string | number | boolean>;
}
export interface ApiChatCompletionRequest {
@@ -1,3 +1,4 @@
import { base } from '$app/paths';
import { error } from '@sveltejs/kit';
import { browser } from '$app/environment';
import { config } from '$lib/stores/settings.svelte';
@@ -22,7 +23,7 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`./props`, { headers });
const response = await fetch(`${base}/props`, { headers });
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import '../app.css';
import { base } from '$app/paths';
import { page } from '$app/state';
import { untrack } from 'svelte';
import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
@@ -157,7 +158,7 @@
headers.Authorization = `Bearer ${apiKey.trim()}`;
}
fetch(`./props`, { headers })
fetch(`${base}/props`, { headers })
.then((response) => {
if (response.status === 401 || response.status === 403) {
window.location.reload();