aboutsummaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings
diff options
context:
space:
mode:
authorMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
committerMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
commitb333b06772c89d96aacb5490d6a219fba7c09cc6 (patch)
tree211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings
downloadllmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte508
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte255
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte59
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte317
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte18
5 files changed, 1157 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
new file mode 100644
index 0000000..5a668aa
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
@@ -0,0 +1,508 @@
1<script lang="ts">
2 import {
3 Settings,
4 Funnel,
5 AlertTriangle,
6 Code,
7 Monitor,
8 Sun,
9 Moon,
10 ChevronLeft,
11 ChevronRight,
12 Database
13 } from '@lucide/svelte';
14 import {
15 ChatSettingsFooter,
16 ChatSettingsImportExportTab,
17 ChatSettingsFields
18 } from '$lib/components/app';
19 import { ScrollArea } from '$lib/components/ui/scroll-area';
20 import { config, settingsStore } from '$lib/stores/settings.svelte';
21 import { setMode } from 'mode-watcher';
22 import type { Component } from 'svelte';
23
24 interface Props {
25 onSave?: () => void;
26 }
27
28 let { onSave }: Props = $props();
29
30 const settingSections: Array<{
31 fields: SettingsFieldConfig[];
32 icon: Component;
33 title: string;
34 }> = [
35 {
36 title: 'General',
37 icon: Settings,
38 fields: [
39 {
40 key: 'theme',
41 label: 'Theme',
42 type: 'select',
43 options: [
44 { value: 'system', label: 'System', icon: Monitor },
45 { value: 'light', label: 'Light', icon: Sun },
46 { value: 'dark', label: 'Dark', icon: Moon }
47 ]
48 },
49 { key: 'apiKey', label: 'API Key', type: 'input' },
50 {
51 key: 'systemMessage',
52 label: 'System Message',
53 type: 'textarea'
54 },
55 {
56 key: 'pasteLongTextToFileLen',
57 label: 'Paste long text to file length',
58 type: 'input'
59 },
60 {
61 key: 'copyTextAttachmentsAsPlainText',
62 label: 'Copy text attachments as plain text',
63 type: 'checkbox'
64 },
65 {
66 key: 'enableContinueGeneration',
67 label: 'Enable "Continue" button',
68 type: 'checkbox',
69 isExperimental: true
70 },
71 {
72 key: 'pdfAsImage',
73 label: 'Parse PDF as image',
74 type: 'checkbox'
75 },
76 {
77 key: 'askForTitleConfirmation',
78 label: 'Ask for confirmation before changing conversation title',
79 type: 'checkbox'
80 }
81 ]
82 },
83 {
84 title: 'Display',
85 icon: Monitor,
86 fields: [
87 {
88 key: 'showMessageStats',
89 label: 'Show message generation statistics',
90 type: 'checkbox'
91 },
92 {
93 key: 'showThoughtInProgress',
94 label: 'Show thought in progress',
95 type: 'checkbox'
96 },
97 {
98 key: 'keepStatsVisible',
99 label: 'Keep stats visible after generation',
100 type: 'checkbox'
101 },
102 {
103 key: 'autoMicOnEmpty',
104 label: 'Show microphone on empty input',
105 type: 'checkbox',
106 isExperimental: true
107 },
108 {
109 key: 'renderUserContentAsMarkdown',
110 label: 'Render user content as Markdown',
111 type: 'checkbox'
112 },
113 {
114 key: 'disableAutoScroll',
115 label: 'Disable automatic scroll',
116 type: 'checkbox'
117 },
118 {
119 key: 'alwaysShowSidebarOnDesktop',
120 label: 'Always show sidebar on desktop',
121 type: 'checkbox'
122 },
123 {
124 key: 'autoShowSidebarOnNewChat',
125 label: 'Auto-show sidebar on new chat',
126 type: 'checkbox'
127 }
128 ]
129 },
130 {
131 title: 'Sampling',
132 icon: Funnel,
133 fields: [
134 {
135 key: 'temperature',
136 label: 'Temperature',
137 type: 'input'
138 },
139 {
140 key: 'dynatemp_range',
141 label: 'Dynamic temperature range',
142 type: 'input'
143 },
144 {
145 key: 'dynatemp_exponent',
146 label: 'Dynamic temperature exponent',
147 type: 'input'
148 },
149 {
150 key: 'top_k',
151 label: 'Top K',
152 type: 'input'
153 },
154 {
155 key: 'top_p',
156 label: 'Top P',
157 type: 'input'
158 },
159 {
160 key: 'min_p',
161 label: 'Min P',
162 type: 'input'
163 },
164 {
165 key: 'xtc_probability',
166 label: 'XTC probability',
167 type: 'input'
168 },
169 {
170 key: 'xtc_threshold',
171 label: 'XTC threshold',
172 type: 'input'
173 },
174 {
175 key: 'typ_p',
176 label: 'Typical P',
177 type: 'input'
178 },
179 {
180 key: 'max_tokens',
181 label: 'Max tokens',
182 type: 'input'
183 },
184 {
185 key: 'samplers',
186 label: 'Samplers',
187 type: 'input'
188 },
189 {
190 key: 'backend_sampling',
191 label: 'Backend sampling',
192 type: 'checkbox'
193 }
194 ]
195 },
196 {
197 title: 'Penalties',
198 icon: AlertTriangle,
199 fields: [
200 {
201 key: 'repeat_last_n',
202 label: 'Repeat last N',
203 type: 'input'
204 },
205 {
206 key: 'repeat_penalty',
207 label: 'Repeat penalty',
208 type: 'input'
209 },
210 {
211 key: 'presence_penalty',
212 label: 'Presence penalty',
213 type: 'input'
214 },
215 {
216 key: 'frequency_penalty',
217 label: 'Frequency penalty',
218 type: 'input'
219 },
220 {
221 key: 'dry_multiplier',
222 label: 'DRY multiplier',
223 type: 'input'
224 },
225 {
226 key: 'dry_base',
227 label: 'DRY base',
228 type: 'input'
229 },
230 {
231 key: 'dry_allowed_length',
232 label: 'DRY allowed length',
233 type: 'input'
234 },
235 {
236 key: 'dry_penalty_last_n',
237 label: 'DRY penalty last N',
238 type: 'input'
239 }
240 ]
241 },
242 {
243 title: 'Import/Export',
244 icon: Database,
245 fields: []
246 },
247 {
248 title: 'Developer',
249 icon: Code,
250 fields: [
251 {
252 key: 'showToolCalls',
253 label: 'Show tool call labels',
254 type: 'checkbox'
255 },
256 {
257 key: 'disableReasoningFormat',
258 label: 'Show raw LLM output',
259 type: 'checkbox'
260 },
261 {
262 key: 'custom',
263 label: 'Custom JSON',
264 type: 'textarea'
265 }
266 ]
267 }
268 // TODO: Experimental features section will be implemented after initial release
269 // This includes Python interpreter (Pyodide integration) and other experimental features
270 // {
271 // title: 'Experimental',
272 // icon: Beaker,
273 // fields: [
274 // {
275 // key: 'pyInterpreterEnabled',
276 // label: 'Enable Python interpreter',
277 // type: 'checkbox'
278 // }
279 // ]
280 // }
281 ];
282
283 let activeSection = $state('General');
284 let currentSection = $derived(
285 settingSections.find((section) => section.title === activeSection) || settingSections[0]
286 );
287 let localConfig: SettingsConfigType = $state({ ...config() });
288
289 let canScrollLeft = $state(false);
290 let canScrollRight = $state(false);
291 let scrollContainer: HTMLDivElement | undefined = $state();
292
293 function handleThemeChange(newTheme: string) {
294 localConfig.theme = newTheme;
295
296 setMode(newTheme as 'light' | 'dark' | 'system');
297 }
298
299 function handleConfigChange(key: string, value: string | boolean) {
300 localConfig[key] = value;
301 }
302
303 function handleReset() {
304 localConfig = { ...config() };
305
306 setMode(localConfig.theme as 'light' | 'dark' | 'system');
307 }
308
309 function handleSave() {
310 if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
311 try {
312 JSON.parse(localConfig.custom);
313 } catch (error) {
314 alert('Invalid JSON in custom parameters. Please check the format and try again.');
315 console.error(error);
316 return;
317 }
318 }
319
320 // Convert numeric strings to numbers for numeric fields
321 const processedConfig = { ...localConfig };
322 const numericFields = [
323 'temperature',
324 'top_k',
325 'top_p',
326 'min_p',
327 'max_tokens',
328 'pasteLongTextToFileLen',
329 'dynatemp_range',
330 'dynatemp_exponent',
331 'typ_p',
332 'xtc_probability',
333 'xtc_threshold',
334 'repeat_last_n',
335 'repeat_penalty',
336 'presence_penalty',
337 'frequency_penalty',
338 'dry_multiplier',
339 'dry_base',
340 'dry_allowed_length',
341 'dry_penalty_last_n'
342 ];
343
344 for (const field of numericFields) {
345 if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
346 const numValue = Number(processedConfig[field]);
347 if (!isNaN(numValue)) {
348 processedConfig[field] = numValue;
349 } else {
350 alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
351 return;
352 }
353 }
354 }
355
356 settingsStore.updateMultipleConfig(processedConfig);
357 onSave?.();
358 }
359
360 function scrollToCenter(element: HTMLElement) {
361 if (!scrollContainer) return;
362
363 const containerRect = scrollContainer.getBoundingClientRect();
364 const elementRect = element.getBoundingClientRect();
365
366 const elementCenter = elementRect.left + elementRect.width / 2;
367 const containerCenter = containerRect.left + containerRect.width / 2;
368 const scrollOffset = elementCenter - containerCenter;
369
370 scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
371 }
372
373 function scrollLeft() {
374 if (!scrollContainer) return;
375
376 scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
377 }
378
379 function scrollRight() {
380 if (!scrollContainer) return;
381
382 scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
383 }
384
385 function updateScrollButtons() {
386 if (!scrollContainer) return;
387
388 const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
389 canScrollLeft = scrollLeft > 0;
390 canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
391 }
392
393 export function reset() {
394 localConfig = { ...config() };
395
396 setTimeout(updateScrollButtons, 100);
397 }
398
399 $effect(() => {
400 if (scrollContainer) {
401 updateScrollButtons();
402 }
403 });
404</script>
405
406<div class="flex h-full flex-col overflow-hidden md:flex-row">
407 <!-- Desktop Sidebar -->
408 <div class="hidden w-64 border-r border-border/30 p-6 md:block">
409 <nav class="space-y-1 py-2">
410 {#each settingSections as section (section.title)}
411 <button
412 class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
413 section.title
414 ? 'bg-accent text-accent-foreground'
415 : 'text-muted-foreground'}"
416 onclick={() => (activeSection = section.title)}
417 >
418 <section.icon class="h-4 w-4" />
419
420 <span class="ml-2">{section.title}</span>
421 </button>
422 {/each}
423 </nav>
424 </div>
425
426 <!-- Mobile Header with Horizontal Scrollable Menu -->
427 <div class="flex flex-col pt-6 md:hidden">
428 <div class="border-b border-border/30 py-4">
429 <!-- Horizontal Scrollable Category Menu with Navigation -->
430 <div class="relative flex items-center" style="scroll-padding: 1rem;">
431 <button
432 class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
433 ? 'opacity-100'
434 : 'pointer-events-none opacity-0'}"
435 onclick={scrollLeft}
436 aria-label="Scroll left"
437 >
438 <ChevronLeft class="h-4 w-4" />
439 </button>
440
441 <div
442 class="scrollbar-hide overflow-x-auto py-2"
443 bind:this={scrollContainer}
444 onscroll={updateScrollButtons}
445 >
446 <div class="flex min-w-max gap-2">
447 {#each settingSections as section (section.title)}
448 <button
449 class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
450 section.title
451 ? 'bg-accent text-accent-foreground'
452 : 'text-muted-foreground'}"
453 onclick={(e: MouseEvent) => {
454 activeSection = section.title;
455 scrollToCenter(e.currentTarget as HTMLElement);
456 }}
457 >
458 <section.icon class="h-4 w-4 flex-shrink-0" />
459 <span>{section.title}</span>
460 </button>
461 {/each}
462 </div>
463 </div>
464
465 <button
466 class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
467 ? 'opacity-100'
468 : 'pointer-events-none opacity-0'}"
469 onclick={scrollRight}
470 aria-label="Scroll right"
471 >
472 <ChevronRight class="h-4 w-4" />
473 </button>
474 </div>
475 </div>
476 </div>
477
478 <ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
479 <div class="space-y-6 p-4 md:p-6">
480 <div class="grid">
481 <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
482 <currentSection.icon class="h-5 w-5" />
483
484 <h3 class="text-lg font-semibold">{currentSection.title}</h3>
485 </div>
486
487 {#if currentSection.title === 'Import/Export'}
488 <ChatSettingsImportExportTab />
489 {:else}
490 <div class="space-y-6">
491 <ChatSettingsFields
492 fields={currentSection.fields}
493 {localConfig}
494 onConfigChange={handleConfigChange}
495 onThemeChange={handleThemeChange}
496 />
497 </div>
498 {/if}
499 </div>
500
501 <div class="mt-8 border-t pt-6">
502 <p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
503 </div>
504 </div>
505 </ScrollArea>
506</div>
507
508<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
new file mode 100644
index 0000000..a6f51f4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
@@ -0,0 +1,255 @@
1<script lang="ts">
2 import { RotateCcw, FlaskConical } from '@lucide/svelte';
3 import { Checkbox } from '$lib/components/ui/checkbox';
4 import { Input } from '$lib/components/ui/input';
5 import Label from '$lib/components/ui/label/label.svelte';
6 import * as Select from '$lib/components/ui/select';
7 import { Textarea } from '$lib/components/ui/textarea';
8 import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
9 import { settingsStore } from '$lib/stores/settings.svelte';
10 import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
11 import type { Component } from 'svelte';
12
13 interface Props {
14 fields: SettingsFieldConfig[];
15 localConfig: SettingsConfigType;
16 onConfigChange: (key: string, value: string | boolean) => void;
17 onThemeChange?: (theme: string) => void;
18 }
19
20 let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
21
22 // Helper function to get parameter source info for syncable parameters
23 function getParameterSourceInfo(key: string) {
24 if (!settingsStore.canSyncParameter(key)) {
25 return null;
26 }
27
28 return settingsStore.getParameterInfo(key);
29 }
30</script>
31
32{#each fields as field (field.key)}
33 <div class="space-y-2">
34 {#if field.type === 'input'}
35 {@const paramInfo = getParameterSourceInfo(field.key)}
36 {@const currentValue = String(localConfig[field.key] ?? '')}
37 {@const propsDefault = paramInfo?.serverDefault}
38 {@const isCustomRealTime = (() => {
39 if (!paramInfo || propsDefault === undefined) return false;
40
41 // Apply same rounding logic for real-time comparison
42 const inputValue = currentValue;
43 const numericInput = parseFloat(inputValue);
44 const normalizedInput = !isNaN(numericInput)
45 ? Math.round(numericInput * 1000000) / 1000000
46 : inputValue;
47 const normalizedDefault =
48 typeof propsDefault === 'number'
49 ? Math.round(propsDefault * 1000000) / 1000000
50 : propsDefault;
51
52 return normalizedInput !== normalizedDefault;
53 })()}
54
55 <div class="flex items-center gap-2">
56 <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
57 {field.label}
58
59 {#if field.isExperimental}
60 <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
61 {/if}
62 </Label>
63 {#if isCustomRealTime}
64 <ChatSettingsParameterSourceIndicator />
65 {/if}
66 </div>
67
68 <div class="relative w-full md:max-w-md">
69 <Input
70 id={field.key}
71 value={currentValue}
72 oninput={(e) => {
73 // Update local config immediately for real-time badge feedback
74 onConfigChange(field.key, e.currentTarget.value);
75 }}
76 placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
77 class="w-full {isCustomRealTime ? 'pr-8' : ''}"
78 />
79 {#if isCustomRealTime}
80 <button
81 type="button"
82 onclick={() => {
83 settingsStore.resetParameterToServerDefault(field.key);
84 // Trigger UI update by calling onConfigChange with the default value
85 const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
86 onConfigChange(field.key, String(defaultValue));
87 }}
88 class="absolute top-1/2 right-2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
89 aria-label="Reset to default"
90 title="Reset to default"
91 >
92 <RotateCcw class="h-3 w-3" />
93 </button>
94 {/if}
95 </div>
96 {#if field.help || SETTING_CONFIG_INFO[field.key]}
97 <p class="mt-1 text-xs text-muted-foreground">
98 {@html field.help || SETTING_CONFIG_INFO[field.key]}
99 </p>
100 {/if}
101 {:else if field.type === 'textarea'}
102 <Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
103 {field.label}
104
105 {#if field.isExperimental}
106 <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
107 {/if}
108 </Label>
109
110 <Textarea
111 id={field.key}
112 value={String(localConfig[field.key] ?? '')}
113 onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
114 placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
115 class="min-h-[10rem] w-full md:max-w-2xl"
116 />
117
118 {#if field.help || SETTING_CONFIG_INFO[field.key]}
119 <p class="mt-1 text-xs text-muted-foreground">
120 {field.help || SETTING_CONFIG_INFO[field.key]}
121 </p>
122 {/if}
123
124 {#if field.key === 'systemMessage'}
125 <div class="mt-3 flex items-center gap-2">
126 <Checkbox
127 id="showSystemMessage"
128 checked={Boolean(localConfig.showSystemMessage ?? true)}
129 onCheckedChange={(checked) => onConfigChange('showSystemMessage', Boolean(checked))}
130 />
131
132 <Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
133 Show system message in conversations
134 </Label>
135 </div>
136 {/if}
137 {:else if field.type === 'select'}
138 {@const selectedOption = field.options?.find(
139 (opt: { value: string; label: string; icon?: Component }) =>
140 opt.value === localConfig[field.key]
141 )}
142 {@const paramInfo = getParameterSourceInfo(field.key)}
143 {@const currentValue = localConfig[field.key]}
144 {@const propsDefault = paramInfo?.serverDefault}
145 {@const isCustomRealTime = (() => {
146 if (!paramInfo || propsDefault === undefined) return false;
147
148 // For select fields, do direct comparison (no rounding needed)
149 return currentValue !== propsDefault;
150 })()}
151
152 <div class="flex items-center gap-2">
153 <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
154 {field.label}
155
156 {#if field.isExperimental}
157 <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
158 {/if}
159 </Label>
160 {#if isCustomRealTime}
161 <ChatSettingsParameterSourceIndicator />
162 {/if}
163 </div>
164
165 <Select.Root
166 type="single"
167 value={currentValue}
168 onValueChange={(value) => {
169 if (field.key === 'theme' && value && onThemeChange) {
170 onThemeChange(value);
171 } else {
172 onConfigChange(field.key, value);
173 }
174 }}
175 >
176 <div class="relative w-full md:w-auto md:max-w-md">
177 <Select.Trigger class="w-full">
178 <div class="flex items-center gap-2">
179 {#if selectedOption?.icon}
180 {@const IconComponent = selectedOption.icon}
181 <IconComponent class="h-4 w-4" />
182 {/if}
183
184 {selectedOption?.label || `Select ${field.label.toLowerCase()}`}
185 </div>
186 </Select.Trigger>
187 {#if isCustomRealTime}
188 <button
189 type="button"
190 onclick={() => {
191 settingsStore.resetParameterToServerDefault(field.key);
192 // Trigger UI update by calling onConfigChange with the default value
193 const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
194 onConfigChange(field.key, String(defaultValue));
195 }}
196 class="absolute top-1/2 right-8 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
197 aria-label="Reset to default"
198 title="Reset to default"
199 >
200 <RotateCcw class="h-3 w-3" />
201 </button>
202 {/if}
203 </div>
204 <Select.Content>
205 {#if field.options}
206 {#each field.options as option (option.value)}
207 <Select.Item value={option.value} label={option.label}>
208 <div class="flex items-center gap-2">
209 {#if option.icon}
210 {@const IconComponent = option.icon}
211 <IconComponent class="h-4 w-4" />
212 {/if}
213 {option.label}
214 </div>
215 </Select.Item>
216 {/each}
217 {/if}
218 </Select.Content>
219 </Select.Root>
220 {#if field.help || SETTING_CONFIG_INFO[field.key]}
221 <p class="mt-1 text-xs text-muted-foreground">
222 {field.help || SETTING_CONFIG_INFO[field.key]}
223 </p>
224 {/if}
225 {:else if field.type === 'checkbox'}
226 <div class="flex items-start space-x-3">
227 <Checkbox
228 id={field.key}
229 checked={Boolean(localConfig[field.key])}
230 onCheckedChange={(checked) => onConfigChange(field.key, checked)}
231 class="mt-1"
232 />
233
234 <div class="space-y-1">
235 <label
236 for={field.key}
237 class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
238 >
239 {field.label}
240
241 {#if field.isExperimental}
242 <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
243 {/if}
244 </label>
245
246 {#if field.help || SETTING_CONFIG_INFO[field.key]}
247 <p class="text-xs text-muted-foreground">
248 {field.help || SETTING_CONFIG_INFO[field.key]}
249 </p>
250 {/if}
251 </div>
252 </div>
253 {/if}
254 </div>
255{/each}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte
new file mode 100644
index 0000000..1f7eb4e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte
@@ -0,0 +1,59 @@
1<script lang="ts">
2 import { Button } from '$lib/components/ui/button';
3 import * as AlertDialog from '$lib/components/ui/alert-dialog';
4 import { settingsStore } from '$lib/stores/settings.svelte';
5 import { RotateCcw } from '@lucide/svelte';
6
7 interface Props {
8 onReset?: () => void;
9 onSave?: () => void;
10 }
11
12 let { onReset, onSave }: Props = $props();
13
14 let showResetDialog = $state(false);
15
16 function handleResetClick() {
17 showResetDialog = true;
18 }
19
20 function handleConfirmReset() {
21 settingsStore.forceSyncWithServerDefaults();
22 onReset?.();
23
24 showResetDialog = false;
25 }
26
27 function handleSave() {
28 onSave?.();
29 }
30</script>
31
32<div class="flex justify-between border-t border-border/30 p-6">
33 <div class="flex gap-2">
34 <Button variant="outline" onclick={handleResetClick}>
35 <RotateCcw class="h-3 w-3" />
36
37 Reset to default
38 </Button>
39 </div>
40
41 <Button onclick={handleSave}>Save settings</Button>
42</div>
43
44<AlertDialog.Root bind:open={showResetDialog}>
45 <AlertDialog.Content>
46 <AlertDialog.Header>
47 <AlertDialog.Title>Reset Settings to Default</AlertDialog.Title>
48 <AlertDialog.Description>
49 Are you sure you want to reset all settings to their default values? This will reset all
50 parameters to the values provided by the server's /props endpoint and remove all your custom
51 configurations.
52 </AlertDialog.Description>
53 </AlertDialog.Header>
54 <AlertDialog.Footer>
55 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
56 <AlertDialog.Action onclick={handleConfirmReset}>Reset to Default</AlertDialog.Action>
57 </AlertDialog.Footer>
58 </AlertDialog.Content>
59</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte
new file mode 100644
index 0000000..1c8b411
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte
@@ -0,0 +1,317 @@
1<script lang="ts">
2 import { Download, Upload, Trash2 } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import { DialogConversationSelection } from '$lib/components/app';
5 import { createMessageCountMap } from '$lib/utils';
6 import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
7 import { toast } from 'svelte-sonner';
8 import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
9
10 let exportedConversations = $state<DatabaseConversation[]>([]);
11 let importedConversations = $state<DatabaseConversation[]>([]);
12 let showExportSummary = $state(false);
13 let showImportSummary = $state(false);
14
15 let showExportDialog = $state(false);
16 let showImportDialog = $state(false);
17 let availableConversations = $state<DatabaseConversation[]>([]);
18 let messageCountMap = $state<Map<string, number>>(new Map());
19 let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
20 []
21 );
22
23 // Delete functionality state
24 let showDeleteDialog = $state(false);
25
26 async function handleExportClick() {
27 try {
28 const allConversations = conversations();
29 if (allConversations.length === 0) {
30 toast.info('No conversations to export');
31 return;
32 }
33
34 const conversationsWithMessages = await Promise.all(
35 allConversations.map(async (conv: DatabaseConversation) => {
36 const messages = await conversationsStore.getConversationMessages(conv.id);
37 return { conv, messages };
38 })
39 );
40
41 messageCountMap = createMessageCountMap(conversationsWithMessages);
42 availableConversations = allConversations;
43 showExportDialog = true;
44 } catch (err) {
45 console.error('Failed to load conversations:', err);
46 alert('Failed to load conversations');
47 }
48 }
49
50 async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
51 try {
52 const allData: ExportedConversations = await Promise.all(
53 selectedConversations.map(async (conv) => {
54 const messages = await conversationsStore.getConversationMessages(conv.id);
55 return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
56 })
57 );
58
59 const blob = new Blob([JSON.stringify(allData, null, 2)], {
60 type: 'application/json'
61 });
62 const url = URL.createObjectURL(blob);
63 const a = document.createElement('a');
64
65 a.href = url;
66 a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
67 document.body.appendChild(a);
68 a.click();
69 document.body.removeChild(a);
70 URL.revokeObjectURL(url);
71
72 exportedConversations = selectedConversations;
73 showExportSummary = true;
74 showImportSummary = false;
75 showExportDialog = false;
76 } catch (err) {
77 console.error('Export failed:', err);
78 alert('Failed to export conversations');
79 }
80 }
81
82 async function handleImportClick() {
83 try {
84 const input = document.createElement('input');
85
86 input.type = 'file';
87 input.accept = '.json';
88
89 input.onchange = async (e) => {
90 const file = (e.target as HTMLInputElement)?.files?.[0];
91 if (!file) return;
92
93 try {
94 const text = await file.text();
95 const parsedData = JSON.parse(text);
96 let importedData: ExportedConversations;
97
98 if (Array.isArray(parsedData)) {
99 importedData = parsedData;
100 } else if (
101 parsedData &&
102 typeof parsedData === 'object' &&
103 'conv' in parsedData &&
104 'messages' in parsedData
105 ) {
106 // Single conversation object
107 importedData = [parsedData];
108 } else {
109 throw new Error(
110 'Invalid file format: expected array of conversations or single conversation object'
111 );
112 }
113
114 fullImportData = importedData;
115 availableConversations = importedData.map(
116 (item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
117 );
118 messageCountMap = createMessageCountMap(importedData);
119 showImportDialog = true;
120 } catch (err: unknown) {
121 const message = err instanceof Error ? err.message : 'Unknown error';
122
123 console.error('Failed to parse file:', err);
124 alert(`Failed to parse file: ${message}`);
125 }
126 };
127
128 input.click();
129 } catch (err) {
130 console.error('Import failed:', err);
131 alert('Failed to import conversations');
132 }
133 }
134
135 async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
136 try {
137 const selectedIds = new Set(selectedConversations.map((c) => c.id));
138 const selectedData = $state
139 .snapshot(fullImportData)
140 .filter((item) => selectedIds.has(item.conv.id));
141
142 await conversationsStore.importConversationsData(selectedData);
143
144 importedConversations = selectedConversations;
145 showImportSummary = true;
146 showExportSummary = false;
147 showImportDialog = false;
148 } catch (err) {
149 console.error('Import failed:', err);
150 alert('Failed to import conversations. Please check the file format.');
151 }
152 }
153
154 async function handleDeleteAllClick() {
155 try {
156 const allConversations = conversations();
157
158 if (allConversations.length === 0) {
159 toast.info('No conversations to delete');
160 return;
161 }
162
163 showDeleteDialog = true;
164 } catch (err) {
165 console.error('Failed to load conversations for deletion:', err);
166 toast.error('Failed to load conversations');
167 }
168 }
169
170 async function handleDeleteAllConfirm() {
171 try {
172 await conversationsStore.deleteAll();
173
174 showDeleteDialog = false;
175 } catch (err) {
176 console.error('Failed to delete conversations:', err);
177 }
178 }
179
180 function handleDeleteAllCancel() {
181 showDeleteDialog = false;
182 }
183</script>
184
185<div class="space-y-6">
186 <div class="space-y-4">
187 <div class="grid">
188 <h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
189
190 <p class="mb-4 text-sm text-muted-foreground">
191 Download all your conversations as a JSON file. This includes all messages, attachments, and
192 conversation history.
193 </p>
194
195 <Button
196 class="w-full justify-start justify-self-start md:w-auto"
197 onclick={handleExportClick}
198 variant="outline"
199 >
200 <Download class="mr-2 h-4 w-4" />
201
202 Export conversations
203 </Button>
204
205 {#if showExportSummary && exportedConversations.length > 0}
206 <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
207 <h5 class="mb-2 text-sm font-medium">
208 Exported {exportedConversations.length} conversation{exportedConversations.length === 1
209 ? ''
210 : 's'}
211 </h5>
212
213 <ul class="space-y-1 text-sm text-muted-foreground">
214 {#each exportedConversations.slice(0, 10) as conv (conv.id)}
215 <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
216 {/each}
217
218 {#if exportedConversations.length > 10}
219 <li class="italic">
220 ... and {exportedConversations.length - 10} more
221 </li>
222 {/if}
223 </ul>
224 </div>
225 {/if}
226 </div>
227
228 <div class="grid border-t border-border/30 pt-4">
229 <h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
230
231 <p class="mb-4 text-sm text-muted-foreground">
232 Import one or more conversations from a previously exported JSON file. This will merge with
233 your existing conversations.
234 </p>
235
236 <Button
237 class="w-full justify-start justify-self-start md:w-auto"
238 onclick={handleImportClick}
239 variant="outline"
240 >
241 <Upload class="mr-2 h-4 w-4" />
242 Import conversations
243 </Button>
244
245 {#if showImportSummary && importedConversations.length > 0}
246 <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
247 <h5 class="mb-2 text-sm font-medium">
248 Imported {importedConversations.length} conversation{importedConversations.length === 1
249 ? ''
250 : 's'}
251 </h5>
252
253 <ul class="space-y-1 text-sm text-muted-foreground">
254 {#each importedConversations.slice(0, 10) as conv (conv.id)}
255 <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
256 {/each}
257
258 {#if importedConversations.length > 10}
259 <li class="italic">
260 ... and {importedConversations.length - 10} more
261 </li>
262 {/if}
263 </ul>
264 </div>
265 {/if}
266 </div>
267
268 <div class="grid border-t border-border/30 pt-4">
269 <h4 class="mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
270
271 <p class="mb-4 text-sm text-muted-foreground">
272 Permanently delete all conversations and their messages. This action cannot be undone.
273 Consider exporting your conversations first if you want to keep a backup.
274 </p>
275
276 <Button
277 class="text-destructive-foreground w-full justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto"
278 onclick={handleDeleteAllClick}
279 variant="destructive"
280 >
281 <Trash2 class="mr-2 h-4 w-4" />
282
283 Delete all conversations
284 </Button>
285 </div>
286 </div>
287</div>
288
289<DialogConversationSelection
290 conversations={availableConversations}
291 {messageCountMap}
292 mode="export"
293 bind:open={showExportDialog}
294 onCancel={() => (showExportDialog = false)}
295 onConfirm={handleExportConfirm}
296/>
297
298<DialogConversationSelection
299 conversations={availableConversations}
300 {messageCountMap}
301 mode="import"
302 bind:open={showImportDialog}
303 onCancel={() => (showImportDialog = false)}
304 onConfirm={handleImportConfirm}
305/>
306
307<DialogConfirmation
308 bind:open={showDeleteDialog}
309 title="Delete all conversations"
310 description="Are you sure you want to delete all conversations? This action cannot be undone and will permanently remove all your conversations and messages."
311 confirmText="Delete All"
312 cancelText="Cancel"
313 variant="destructive"
314 icon={Trash2}
315 onConfirm={handleDeleteAllConfirm}
316 onCancel={handleDeleteAllCancel}
317/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte
new file mode 100644
index 0000000..b566985
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte
@@ -0,0 +1,18 @@
1<script lang="ts">
2 import { Wrench } from '@lucide/svelte';
3 import { Badge } from '$lib/components/ui/badge';
4
5 interface Props {
6 class?: string;
7 }
8
9 let { class: className = '' }: Props = $props();
10</script>
11
12<Badge
13 variant="secondary"
14 class="h-5 bg-orange-100 px-1.5 py-0.5 text-xs text-orange-800 dark:bg-orange-900 dark:text-orange-200 {className}"
15>
16 <Wrench class="mr-1 h-3 w-3" />
17 Custom
18</Badge>