1/**
2 * ParameterSyncService - Handles synchronization between server defaults and user settings
3 *
4 * This service manages the complex logic of merging server-provided default parameters
5 * with user-configured overrides, ensuring the UI reflects the actual server state
6 * while preserving user customizations.
7 *
8 * **Key Responsibilities:**
9 * - Extract syncable parameters from server props
10 * - Merge server defaults with user overrides
11 * - Track parameter sources (server, user, default)
12 * - Provide sync utilities for settings store integration
13 */
14
15import { normalizeFloatingPoint } from '$lib/utils';
16
17export type ParameterSource = 'default' | 'custom';
18export type ParameterValue = string | number | boolean;
19export type ParameterRecord = Record<string, ParameterValue>;
20
21export interface ParameterInfo {
22 value: string | number | boolean;
23 source: ParameterSource;
24 serverDefault?: string | number | boolean;
25 userOverride?: string | number | boolean;
26}
27
28export interface SyncableParameter {
29 key: string;
30 serverKey: string;
31 type: 'number' | 'string' | 'boolean';
32 canSync: boolean;
33}
34
35/**
36 * Mapping of webui setting keys to server parameter keys
37 * Only parameters that should be synced from server are included
38 */
39export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
40 { key: 'temperature', serverKey: 'temperature', type: 'number', canSync: true },
41 { key: 'top_k', serverKey: 'top_k', type: 'number', canSync: true },
42 { key: 'top_p', serverKey: 'top_p', type: 'number', canSync: true },
43 { key: 'min_p', serverKey: 'min_p', type: 'number', canSync: true },
44 { key: 'dynatemp_range', serverKey: 'dynatemp_range', type: 'number', canSync: true },
45 { key: 'dynatemp_exponent', serverKey: 'dynatemp_exponent', type: 'number', canSync: true },
46 { key: 'xtc_probability', serverKey: 'xtc_probability', type: 'number', canSync: true },
47 { key: 'xtc_threshold', serverKey: 'xtc_threshold', type: 'number', canSync: true },
48 { key: 'typ_p', serverKey: 'typ_p', type: 'number', canSync: true },
49 { key: 'repeat_last_n', serverKey: 'repeat_last_n', type: 'number', canSync: true },
50 { key: 'repeat_penalty', serverKey: 'repeat_penalty', type: 'number', canSync: true },
51 { key: 'presence_penalty', serverKey: 'presence_penalty', type: 'number', canSync: true },
52 { key: 'frequency_penalty', serverKey: 'frequency_penalty', type: 'number', canSync: true },
53 { key: 'dry_multiplier', serverKey: 'dry_multiplier', type: 'number', canSync: true },
54 { key: 'dry_base', serverKey: 'dry_base', type: 'number', canSync: true },
55 { key: 'dry_allowed_length', serverKey: 'dry_allowed_length', type: 'number', canSync: true },
56 { key: 'dry_penalty_last_n', serverKey: 'dry_penalty_last_n', type: 'number', canSync: true },
57 { key: 'max_tokens', serverKey: 'max_tokens', type: 'number', canSync: true },
58 { key: 'samplers', serverKey: 'samplers', type: 'string', canSync: true },
59 {
60 key: 'pasteLongTextToFileLen',
61 serverKey: 'pasteLongTextToFileLen',
62 type: 'number',
63 canSync: true
64 },
65 { key: 'pdfAsImage', serverKey: 'pdfAsImage', type: 'boolean', canSync: true },
66 {
67 key: 'showThoughtInProgress',
68 serverKey: 'showThoughtInProgress',
69 type: 'boolean',
70 canSync: true
71 },
72 { key: 'showToolCalls', serverKey: 'showToolCalls', type: 'boolean', canSync: true },
73 {
74 key: 'disableReasoningFormat',
75 serverKey: 'disableReasoningFormat',
76 type: 'boolean',
77 canSync: true
78 },
79 { key: 'keepStatsVisible', serverKey: 'keepStatsVisible', type: 'boolean', canSync: true },
80 { key: 'showMessageStats', serverKey: 'showMessageStats', type: 'boolean', canSync: true },
81 {
82 key: 'askForTitleConfirmation',
83 serverKey: 'askForTitleConfirmation',
84 type: 'boolean',
85 canSync: true
86 },
87 { key: 'disableAutoScroll', serverKey: 'disableAutoScroll', type: 'boolean', canSync: true },
88 {
89 key: 'renderUserContentAsMarkdown',
90 serverKey: 'renderUserContentAsMarkdown',
91 type: 'boolean',
92 canSync: true
93 },
94 { key: 'autoMicOnEmpty', serverKey: 'autoMicOnEmpty', type: 'boolean', canSync: true },
95 {
96 key: 'pyInterpreterEnabled',
97 serverKey: 'pyInterpreterEnabled',
98 type: 'boolean',
99 canSync: true
100 },
101 {
102 key: 'enableContinueGeneration',
103 serverKey: 'enableContinueGeneration',
104 type: 'boolean',
105 canSync: true
106 }
107];
108
109export class ParameterSyncService {
110 // ─────────────────────────────────────────────────────────────────────────────
111 // Extraction
112 // ─────────────────────────────────────────────────────────────────────────────
113
114 /**
115 * Round floating-point numbers to avoid JavaScript precision issues
116 */
117 private static roundFloatingPoint(value: ParameterValue): ParameterValue {
118 return normalizeFloatingPoint(value) as ParameterValue;
119 }
120
121 /**
122 * Extract server default parameters that can be synced
123 */
124 static extractServerDefaults(
125 serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
126 webuiSettings?: Record<string, string | number | boolean>
127 ): ParameterRecord {
128 const extracted: ParameterRecord = {};
129
130 if (serverParams) {
131 for (const param of SYNCABLE_PARAMETERS) {
132 if (param.canSync && param.serverKey in serverParams) {
133 const value = (serverParams as unknown as Record<string, ParameterValue>)[
134 param.serverKey
135 ];
136 if (value !== undefined) {
137 // Apply precision rounding to avoid JavaScript floating-point issues
138 extracted[param.key] = this.roundFloatingPoint(value);
139 }
140 }
141 }
142
143 // Handle samplers array conversion to string
144 if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
145 extracted.samplers = serverParams.samplers.join(';');
146 }
147 }
148
149 if (webuiSettings) {
150 for (const param of SYNCABLE_PARAMETERS) {
151 if (param.canSync && param.serverKey in webuiSettings) {
152 const value = webuiSettings[param.serverKey];
153 if (value !== undefined) {
154 extracted[param.key] = this.roundFloatingPoint(value);
155 }
156 }
157 }
158 }
159
160 return extracted;
161 }
162
163 // ─────────────────────────────────────────────────────────────────────────────
164 // Merging
165 // ─────────────────────────────────────────────────────────────────────────────
166
167 /**
168 * Merge server defaults with current user settings
169 * Returns updated settings that respect user overrides while using server defaults
170 */
171 static mergeWithServerDefaults(
172 currentSettings: ParameterRecord,
173 serverDefaults: ParameterRecord,
174 userOverrides: Set<string> = new Set()
175 ): ParameterRecord {
176 const merged = { ...currentSettings };
177
178 for (const [key, serverValue] of Object.entries(serverDefaults)) {
179 // Only update if user hasn't explicitly overridden this parameter
180 if (!userOverrides.has(key)) {
181 merged[key] = this.roundFloatingPoint(serverValue);
182 }
183 }
184
185 return merged;
186 }
187
188 // ─────────────────────────────────────────────────────────────────────────────
189 // Info
190 // ─────────────────────────────────────────────────────────────────────────────
191
192 /**
193 * Get parameter information including source and values
194 */
195 static getParameterInfo(
196 key: string,
197 currentValue: ParameterValue,
198 propsDefaults: ParameterRecord,
199 userOverrides: Set<string>
200 ): ParameterInfo {
201 const hasPropsDefault = propsDefaults[key] !== undefined;
202 const isUserOverride = userOverrides.has(key);
203
204 // Simple logic: either using default (from props) or custom (user override)
205 const source: ParameterSource = isUserOverride ? 'custom' : 'default';
206
207 return {
208 value: currentValue,
209 source,
210 serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
211 userOverride: isUserOverride ? currentValue : undefined
212 };
213 }
214
215 /**
216 * Check if a parameter can be synced from server
217 */
218 static canSyncParameter(key: string): boolean {
219 return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
220 }
221
222 /**
223 * Get all syncable parameter keys
224 */
225 static getSyncableParameterKeys(): string[] {
226 return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
227 }
228
229 /**
230 * Validate server parameter value
231 */
232 static validateServerParameter(key: string, value: ParameterValue): boolean {
233 const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
234 if (!param) return false;
235
236 switch (param.type) {
237 case 'number':
238 return typeof value === 'number' && !isNaN(value);
239 case 'string':
240 return typeof value === 'string';
241 case 'boolean':
242 return typeof value === 'boolean';
243 default:
244 return false;
245 }
246 }
247
248 // ─────────────────────────────────────────────────────────────────────────────
249 // Diff
250 // ─────────────────────────────────────────────────────────────────────────────
251
252 /**
253 * Create a diff between current settings and server defaults
254 */
255 static createParameterDiff(
256 currentSettings: ParameterRecord,
257 serverDefaults: ParameterRecord
258 ): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
259 const diff: Record<
260 string,
261 { current: ParameterValue; server: ParameterValue; differs: boolean }
262 > = {};
263
264 for (const key of this.getSyncableParameterKeys()) {
265 const currentValue = currentSettings[key];
266 const serverValue = serverDefaults[key];
267
268 if (serverValue !== undefined) {
269 diff[key] = {
270 current: currentValue,
271 server: serverValue,
272 differs: currentValue !== serverValue
273 };
274 }
275 }
276
277 return diff;
278 }
279}