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} />