| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- <script lang="ts">
- import {
- Settings,
- Funnel,
- AlertTriangle,
- Brain,
- Cog,
- Monitor,
- Sun,
- Moon,
- ChevronLeft,
- ChevronRight
- } from '@lucide/svelte';
- import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
- import * as Dialog from '$lib/components/ui/dialog';
- import { ScrollArea } from '$lib/components/ui/scroll-area';
- import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
- import { config, updateMultipleConfig, resetConfig } from '$lib/stores/settings.svelte';
- import { setMode } from 'mode-watcher';
- import type { Component } from 'svelte';
- interface Props {
- onOpenChange?: (open: boolean) => void;
- open?: boolean;
- }
- let { onOpenChange, open = false }: Props = $props();
- const settingSections: Array<{
- fields: SettingsFieldConfig[];
- icon: Component;
- title: string;
- }> = [
- {
- title: 'General',
- icon: Settings,
- fields: [
- { key: 'apiKey', label: 'API Key', type: 'input' },
- {
- key: 'systemMessage',
- label: 'System Message (will be disabled if left empty)',
- type: 'textarea'
- },
- {
- key: 'theme',
- label: 'Theme',
- type: 'select',
- options: [
- { value: 'system', label: 'System', icon: Monitor },
- { value: 'light', label: 'Light', icon: Sun },
- { value: 'dark', label: 'Dark', icon: Moon }
- ]
- },
- {
- key: 'showTokensPerSecond',
- label: 'Show tokens per second',
- type: 'checkbox'
- },
- {
- key: 'keepStatsVisible',
- label: 'Keep stats visible after generation',
- type: 'checkbox'
- },
- {
- key: 'askForTitleConfirmation',
- label: 'Ask for confirmation before changing conversation title',
- type: 'checkbox'
- },
- {
- key: 'pasteLongTextToFileLen',
- label: 'Paste long text to file length',
- type: 'input'
- },
- {
- key: 'pdfAsImage',
- label: 'Parse PDF as image',
- type: 'checkbox'
- }
- ]
- },
- {
- title: 'Samplers',
- icon: Funnel,
- fields: [
- {
- key: 'samplers',
- label: 'Samplers',
- type: 'input'
- }
- ]
- },
- {
- title: 'Penalties',
- icon: AlertTriangle,
- fields: [
- {
- key: 'repeat_last_n',
- label: 'Repeat last N',
- type: 'input'
- },
- {
- key: 'repeat_penalty',
- label: 'Repeat penalty',
- type: 'input'
- },
- {
- key: 'presence_penalty',
- label: 'Presence penalty',
- type: 'input'
- },
- {
- key: 'frequency_penalty',
- label: 'Frequency penalty',
- type: 'input'
- },
- {
- key: 'dry_multiplier',
- label: 'DRY multiplier',
- type: 'input'
- },
- {
- key: 'dry_base',
- label: 'DRY base',
- type: 'input'
- },
- {
- key: 'dry_allowed_length',
- label: 'DRY allowed length',
- type: 'input'
- },
- {
- key: 'dry_penalty_last_n',
- label: 'DRY penalty last N',
- type: 'input'
- }
- ]
- },
- {
- title: 'Reasoning',
- icon: Brain,
- fields: [
- {
- key: 'showThoughtInProgress',
- label: 'Show thought in progress',
- type: 'checkbox'
- }
- ]
- },
- {
- title: 'Advanced',
- icon: Cog,
- fields: [
- {
- key: 'temperature',
- label: 'Temperature',
- type: 'input'
- },
- {
- key: 'dynatemp_range',
- label: 'Dynamic temperature range',
- type: 'input'
- },
- {
- key: 'dynatemp_exponent',
- label: 'Dynamic temperature exponent',
- type: 'input'
- },
- {
- key: 'top_k',
- label: 'Top K',
- type: 'input'
- },
- {
- key: 'top_p',
- label: 'Top P',
- type: 'input'
- },
- {
- key: 'min_p',
- label: 'Min P',
- type: 'input'
- },
- {
- key: 'xtc_probability',
- label: 'XTC probability',
- type: 'input'
- },
- {
- key: 'xtc_threshold',
- label: 'XTC threshold',
- type: 'input'
- },
- {
- key: 'typ_p',
- label: 'Typical P',
- type: 'input'
- },
- {
- key: 'max_tokens',
- label: 'Max tokens',
- type: 'input'
- },
- {
- key: 'custom',
- label: 'Custom JSON',
- type: 'textarea'
- }
- ]
- }
- // TODO: Experimental features section will be implemented after initial release
- // This includes Python interpreter (Pyodide integration) and other experimental features
- // {
- // title: 'Experimental',
- // icon: Beaker,
- // fields: [
- // {
- // key: 'pyInterpreterEnabled',
- // label: 'Enable Python interpreter',
- // type: 'checkbox'
- // }
- // ]
- // }
- ];
- let activeSection = $state('General');
- let currentSection = $derived(
- settingSections.find((section) => section.title === activeSection) || settingSections[0]
- );
- let localConfig: SettingsConfigType = $state({ ...config() });
- let originalTheme: string = $state('');
- let canScrollLeft = $state(false);
- let canScrollRight = $state(false);
- let scrollContainer: HTMLDivElement | undefined = $state();
- function handleThemeChange(newTheme: string) {
- localConfig.theme = newTheme;
- setMode(newTheme as 'light' | 'dark' | 'system');
- }
- function handleConfigChange(key: string, value: string | boolean) {
- localConfig[key] = value;
- }
- function handleClose() {
- if (localConfig.theme !== originalTheme) {
- setMode(originalTheme as 'light' | 'dark' | 'system');
- }
- onOpenChange?.(false);
- }
- function handleReset() {
- resetConfig();
- localConfig = { ...SETTING_CONFIG_DEFAULT };
- setMode(SETTING_CONFIG_DEFAULT.theme as 'light' | 'dark' | 'system');
- originalTheme = SETTING_CONFIG_DEFAULT.theme as string;
- }
- function handleSave() {
- // Validate custom JSON if provided
- if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
- try {
- JSON.parse(localConfig.custom);
- } catch (error) {
- alert('Invalid JSON in custom parameters. Please check the format and try again.');
- console.error(error);
- return;
- }
- }
- // Convert numeric strings to numbers for numeric fields
- const processedConfig = { ...localConfig };
- const numericFields = [
- 'temperature',
- 'top_k',
- 'top_p',
- 'min_p',
- 'max_tokens',
- 'pasteLongTextToFileLen',
- 'dynatemp_range',
- 'dynatemp_exponent',
- 'typ_p',
- 'xtc_probability',
- 'xtc_threshold',
- 'repeat_last_n',
- 'repeat_penalty',
- 'presence_penalty',
- 'frequency_penalty',
- 'dry_multiplier',
- 'dry_base',
- 'dry_allowed_length',
- 'dry_penalty_last_n'
- ];
- for (const field of numericFields) {
- if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
- const numValue = Number(processedConfig[field]);
- if (!isNaN(numValue)) {
- processedConfig[field] = numValue;
- } else {
- alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
- return;
- }
- }
- }
- updateMultipleConfig(processedConfig);
- onOpenChange?.(false);
- }
- function scrollToCenter(element: HTMLElement) {
- if (!scrollContainer) return;
- const containerRect = scrollContainer.getBoundingClientRect();
- const elementRect = element.getBoundingClientRect();
- const elementCenter = elementRect.left + elementRect.width / 2;
- const containerCenter = containerRect.left + containerRect.width / 2;
- const scrollOffset = elementCenter - containerCenter;
- scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
- }
- function scrollLeft() {
- if (!scrollContainer) return;
- scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
- }
- function scrollRight() {
- if (!scrollContainer) return;
- scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
- }
- function updateScrollButtons() {
- if (!scrollContainer) return;
- const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
- canScrollLeft = scrollLeft > 0;
- canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
- }
- $effect(() => {
- if (open) {
- localConfig = { ...config() };
- originalTheme = config().theme as string;
- setTimeout(updateScrollButtons, 100);
- }
- });
- $effect(() => {
- if (scrollContainer) {
- updateScrollButtons();
- }
- });
- </script>
- <Dialog.Root {open} onOpenChange={handleClose}>
- <Dialog.Content
- class="z-999999 flex h-[100vh] flex-col gap-0 rounded-none p-0 md:h-[64vh] md:rounded-lg"
- style="max-width: 48rem;"
- >
- <div class="flex flex-1 flex-col overflow-hidden md:flex-row">
- <!-- Desktop Sidebar -->
- <div class="hidden w-64 border-r border-border/30 p-6 md:block">
- <nav class="space-y-1 py-2">
- <Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
- {#each settingSections as section (section.title)}
- <button
- 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 ===
- section.title
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground'}"
- onclick={() => (activeSection = section.title)}
- >
- <section.icon class="h-4 w-4" />
- <span class="ml-2">{section.title}</span>
- </button>
- {/each}
- </nav>
- </div>
- <!-- Mobile Header with Horizontal Scrollable Menu -->
- <div class="flex flex-col md:hidden">
- <div class="border-b border-border/30 py-4">
- <Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title>
- <!-- Horizontal Scrollable Category Menu with Navigation -->
- <div class="relative flex items-center" style="scroll-padding: 1rem;">
- <button
- 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
- ? 'opacity-100'
- : 'pointer-events-none opacity-0'}"
- onclick={scrollLeft}
- aria-label="Scroll left"
- >
- <ChevronLeft class="h-4 w-4" />
- </button>
- <div
- class="scrollbar-hide overflow-x-auto py-2"
- bind:this={scrollContainer}
- onscroll={updateScrollButtons}
- >
- <div class="flex min-w-max gap-2">
- {#each settingSections as section (section.title)}
- <button
- 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 ===
- section.title
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground'}"
- onclick={(e: MouseEvent) => {
- activeSection = section.title;
- scrollToCenter(e.currentTarget as HTMLElement);
- }}
- >
- <section.icon class="h-4 w-4 flex-shrink-0" />
- <span>{section.title}</span>
- </button>
- {/each}
- </div>
- </div>
- <button
- 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
- ? 'opacity-100'
- : 'pointer-events-none opacity-0'}"
- onclick={scrollRight}
- aria-label="Scroll right"
- >
- <ChevronRight class="h-4 w-4" />
- </button>
- </div>
- </div>
- </div>
- <ScrollArea class="max-h-[calc(100vh-13.5rem)] flex-1">
- <div class="space-y-6 p-4 md:p-6">
- <div>
- <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
- <currentSection.icon class="h-5 w-5" />
- <h3 class="text-lg font-semibold">{currentSection.title}</h3>
- </div>
- <div class="space-y-6">
- <ChatSettingsFields
- fields={currentSection.fields}
- {localConfig}
- onConfigChange={handleConfigChange}
- onThemeChange={handleThemeChange}
- />
- </div>
- </div>
- <div class="mt-8 border-t pt-6">
- <p class="text-xs text-muted-foreground">
- Settings are saved in browser's localStorage
- </p>
- </div>
- </div>
- </ScrollArea>
- </div>
- <ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
- </Dialog.Content>
- </Dialog.Root>
|