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}