ChatSettingsDialog.svelte 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. <script lang="ts">
  2. import {
  3. Settings,
  4. Funnel,
  5. AlertTriangle,
  6. Brain,
  7. Cog,
  8. Monitor,
  9. Sun,
  10. Moon,
  11. ChevronLeft,
  12. ChevronRight
  13. } from '@lucide/svelte';
  14. import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
  15. import * as Dialog from '$lib/components/ui/dialog';
  16. import { ScrollArea } from '$lib/components/ui/scroll-area';
  17. import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
  18. import { config, updateMultipleConfig, resetConfig } from '$lib/stores/settings.svelte';
  19. import { setMode } from 'mode-watcher';
  20. import type { Component } from 'svelte';
  21. interface Props {
  22. onOpenChange?: (open: boolean) => void;
  23. open?: boolean;
  24. }
  25. let { onOpenChange, open = false }: Props = $props();
  26. const settingSections: Array<{
  27. fields: SettingsFieldConfig[];
  28. icon: Component;
  29. title: string;
  30. }> = [
  31. {
  32. title: 'General',
  33. icon: Settings,
  34. fields: [
  35. { key: 'apiKey', label: 'API Key', type: 'input' },
  36. {
  37. key: 'systemMessage',
  38. label: 'System Message (will be disabled if left empty)',
  39. type: 'textarea'
  40. },
  41. {
  42. key: 'theme',
  43. label: 'Theme',
  44. type: 'select',
  45. options: [
  46. { value: 'system', label: 'System', icon: Monitor },
  47. { value: 'light', label: 'Light', icon: Sun },
  48. { value: 'dark', label: 'Dark', icon: Moon }
  49. ]
  50. },
  51. {
  52. key: 'showTokensPerSecond',
  53. label: 'Show tokens per second',
  54. type: 'checkbox'
  55. },
  56. {
  57. key: 'keepStatsVisible',
  58. label: 'Keep stats visible after generation',
  59. type: 'checkbox'
  60. },
  61. {
  62. key: 'askForTitleConfirmation',
  63. label: 'Ask for confirmation before changing conversation title',
  64. type: 'checkbox'
  65. },
  66. {
  67. key: 'pasteLongTextToFileLen',
  68. label: 'Paste long text to file length',
  69. type: 'input'
  70. },
  71. {
  72. key: 'pdfAsImage',
  73. label: 'Parse PDF as image',
  74. type: 'checkbox'
  75. }
  76. ]
  77. },
  78. {
  79. title: 'Samplers',
  80. icon: Funnel,
  81. fields: [
  82. {
  83. key: 'samplers',
  84. label: 'Samplers',
  85. type: 'input'
  86. }
  87. ]
  88. },
  89. {
  90. title: 'Penalties',
  91. icon: AlertTriangle,
  92. fields: [
  93. {
  94. key: 'repeat_last_n',
  95. label: 'Repeat last N',
  96. type: 'input'
  97. },
  98. {
  99. key: 'repeat_penalty',
  100. label: 'Repeat penalty',
  101. type: 'input'
  102. },
  103. {
  104. key: 'presence_penalty',
  105. label: 'Presence penalty',
  106. type: 'input'
  107. },
  108. {
  109. key: 'frequency_penalty',
  110. label: 'Frequency penalty',
  111. type: 'input'
  112. },
  113. {
  114. key: 'dry_multiplier',
  115. label: 'DRY multiplier',
  116. type: 'input'
  117. },
  118. {
  119. key: 'dry_base',
  120. label: 'DRY base',
  121. type: 'input'
  122. },
  123. {
  124. key: 'dry_allowed_length',
  125. label: 'DRY allowed length',
  126. type: 'input'
  127. },
  128. {
  129. key: 'dry_penalty_last_n',
  130. label: 'DRY penalty last N',
  131. type: 'input'
  132. }
  133. ]
  134. },
  135. {
  136. title: 'Reasoning',
  137. icon: Brain,
  138. fields: [
  139. {
  140. key: 'showThoughtInProgress',
  141. label: 'Show thought in progress',
  142. type: 'checkbox'
  143. }
  144. ]
  145. },
  146. {
  147. title: 'Advanced',
  148. icon: Cog,
  149. fields: [
  150. {
  151. key: 'temperature',
  152. label: 'Temperature',
  153. type: 'input'
  154. },
  155. {
  156. key: 'dynatemp_range',
  157. label: 'Dynamic temperature range',
  158. type: 'input'
  159. },
  160. {
  161. key: 'dynatemp_exponent',
  162. label: 'Dynamic temperature exponent',
  163. type: 'input'
  164. },
  165. {
  166. key: 'top_k',
  167. label: 'Top K',
  168. type: 'input'
  169. },
  170. {
  171. key: 'top_p',
  172. label: 'Top P',
  173. type: 'input'
  174. },
  175. {
  176. key: 'min_p',
  177. label: 'Min P',
  178. type: 'input'
  179. },
  180. {
  181. key: 'xtc_probability',
  182. label: 'XTC probability',
  183. type: 'input'
  184. },
  185. {
  186. key: 'xtc_threshold',
  187. label: 'XTC threshold',
  188. type: 'input'
  189. },
  190. {
  191. key: 'typ_p',
  192. label: 'Typical P',
  193. type: 'input'
  194. },
  195. {
  196. key: 'max_tokens',
  197. label: 'Max tokens',
  198. type: 'input'
  199. },
  200. {
  201. key: 'custom',
  202. label: 'Custom JSON',
  203. type: 'textarea'
  204. }
  205. ]
  206. }
  207. // TODO: Experimental features section will be implemented after initial release
  208. // This includes Python interpreter (Pyodide integration) and other experimental features
  209. // {
  210. // title: 'Experimental',
  211. // icon: Beaker,
  212. // fields: [
  213. // {
  214. // key: 'pyInterpreterEnabled',
  215. // label: 'Enable Python interpreter',
  216. // type: 'checkbox'
  217. // }
  218. // ]
  219. // }
  220. ];
  221. let activeSection = $state('General');
  222. let currentSection = $derived(
  223. settingSections.find((section) => section.title === activeSection) || settingSections[0]
  224. );
  225. let localConfig: SettingsConfigType = $state({ ...config() });
  226. let originalTheme: string = $state('');
  227. let canScrollLeft = $state(false);
  228. let canScrollRight = $state(false);
  229. let scrollContainer: HTMLDivElement | undefined = $state();
  230. function handleThemeChange(newTheme: string) {
  231. localConfig.theme = newTheme;
  232. setMode(newTheme as 'light' | 'dark' | 'system');
  233. }
  234. function handleConfigChange(key: string, value: string | boolean) {
  235. localConfig[key] = value;
  236. }
  237. function handleClose() {
  238. if (localConfig.theme !== originalTheme) {
  239. setMode(originalTheme as 'light' | 'dark' | 'system');
  240. }
  241. onOpenChange?.(false);
  242. }
  243. function handleReset() {
  244. resetConfig();
  245. localConfig = { ...SETTING_CONFIG_DEFAULT };
  246. setMode(SETTING_CONFIG_DEFAULT.theme as 'light' | 'dark' | 'system');
  247. originalTheme = SETTING_CONFIG_DEFAULT.theme as string;
  248. }
  249. function handleSave() {
  250. // Validate custom JSON if provided
  251. if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
  252. try {
  253. JSON.parse(localConfig.custom);
  254. } catch (error) {
  255. alert('Invalid JSON in custom parameters. Please check the format and try again.');
  256. console.error(error);
  257. return;
  258. }
  259. }
  260. // Convert numeric strings to numbers for numeric fields
  261. const processedConfig = { ...localConfig };
  262. const numericFields = [
  263. 'temperature',
  264. 'top_k',
  265. 'top_p',
  266. 'min_p',
  267. 'max_tokens',
  268. 'pasteLongTextToFileLen',
  269. 'dynatemp_range',
  270. 'dynatemp_exponent',
  271. 'typ_p',
  272. 'xtc_probability',
  273. 'xtc_threshold',
  274. 'repeat_last_n',
  275. 'repeat_penalty',
  276. 'presence_penalty',
  277. 'frequency_penalty',
  278. 'dry_multiplier',
  279. 'dry_base',
  280. 'dry_allowed_length',
  281. 'dry_penalty_last_n'
  282. ];
  283. for (const field of numericFields) {
  284. if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
  285. const numValue = Number(processedConfig[field]);
  286. if (!isNaN(numValue)) {
  287. processedConfig[field] = numValue;
  288. } else {
  289. alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
  290. return;
  291. }
  292. }
  293. }
  294. updateMultipleConfig(processedConfig);
  295. onOpenChange?.(false);
  296. }
  297. function scrollToCenter(element: HTMLElement) {
  298. if (!scrollContainer) return;
  299. const containerRect = scrollContainer.getBoundingClientRect();
  300. const elementRect = element.getBoundingClientRect();
  301. const elementCenter = elementRect.left + elementRect.width / 2;
  302. const containerCenter = containerRect.left + containerRect.width / 2;
  303. const scrollOffset = elementCenter - containerCenter;
  304. scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
  305. }
  306. function scrollLeft() {
  307. if (!scrollContainer) return;
  308. scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
  309. }
  310. function scrollRight() {
  311. if (!scrollContainer) return;
  312. scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
  313. }
  314. function updateScrollButtons() {
  315. if (!scrollContainer) return;
  316. const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
  317. canScrollLeft = scrollLeft > 0;
  318. canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
  319. }
  320. $effect(() => {
  321. if (open) {
  322. localConfig = { ...config() };
  323. originalTheme = config().theme as string;
  324. setTimeout(updateScrollButtons, 100);
  325. }
  326. });
  327. $effect(() => {
  328. if (scrollContainer) {
  329. updateScrollButtons();
  330. }
  331. });
  332. </script>
  333. <Dialog.Root {open} onOpenChange={handleClose}>
  334. <Dialog.Content
  335. class="z-999999 flex h-[100vh] flex-col gap-0 rounded-none p-0 md:h-[64vh] md:rounded-lg"
  336. style="max-width: 48rem;"
  337. >
  338. <div class="flex flex-1 flex-col overflow-hidden md:flex-row">
  339. <!-- Desktop Sidebar -->
  340. <div class="hidden w-64 border-r border-border/30 p-6 md:block">
  341. <nav class="space-y-1 py-2">
  342. <Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
  343. {#each settingSections as section (section.title)}
  344. <button
  345. 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 ===
  346. section.title
  347. ? 'bg-accent text-accent-foreground'
  348. : 'text-muted-foreground'}"
  349. onclick={() => (activeSection = section.title)}
  350. >
  351. <section.icon class="h-4 w-4" />
  352. <span class="ml-2">{section.title}</span>
  353. </button>
  354. {/each}
  355. </nav>
  356. </div>
  357. <!-- Mobile Header with Horizontal Scrollable Menu -->
  358. <div class="flex flex-col md:hidden">
  359. <div class="border-b border-border/30 py-4">
  360. <Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title>
  361. <!-- Horizontal Scrollable Category Menu with Navigation -->
  362. <div class="relative flex items-center" style="scroll-padding: 1rem;">
  363. <button
  364. 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
  365. ? 'opacity-100'
  366. : 'pointer-events-none opacity-0'}"
  367. onclick={scrollLeft}
  368. aria-label="Scroll left"
  369. >
  370. <ChevronLeft class="h-4 w-4" />
  371. </button>
  372. <div
  373. class="scrollbar-hide overflow-x-auto py-2"
  374. bind:this={scrollContainer}
  375. onscroll={updateScrollButtons}
  376. >
  377. <div class="flex min-w-max gap-2">
  378. {#each settingSections as section (section.title)}
  379. <button
  380. 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 ===
  381. section.title
  382. ? 'bg-accent text-accent-foreground'
  383. : 'text-muted-foreground'}"
  384. onclick={(e: MouseEvent) => {
  385. activeSection = section.title;
  386. scrollToCenter(e.currentTarget as HTMLElement);
  387. }}
  388. >
  389. <section.icon class="h-4 w-4 flex-shrink-0" />
  390. <span>{section.title}</span>
  391. </button>
  392. {/each}
  393. </div>
  394. </div>
  395. <button
  396. 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
  397. ? 'opacity-100'
  398. : 'pointer-events-none opacity-0'}"
  399. onclick={scrollRight}
  400. aria-label="Scroll right"
  401. >
  402. <ChevronRight class="h-4 w-4" />
  403. </button>
  404. </div>
  405. </div>
  406. </div>
  407. <ScrollArea class="max-h-[calc(100vh-13.5rem)] flex-1">
  408. <div class="space-y-6 p-4 md:p-6">
  409. <div>
  410. <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
  411. <currentSection.icon class="h-5 w-5" />
  412. <h3 class="text-lg font-semibold">{currentSection.title}</h3>
  413. </div>
  414. <div class="space-y-6">
  415. <ChatSettingsFields
  416. fields={currentSection.fields}
  417. {localConfig}
  418. onConfigChange={handleConfigChange}
  419. onThemeChange={handleThemeChange}
  420. />
  421. </div>
  422. </div>
  423. <div class="mt-8 border-t pt-6">
  424. <p class="text-xs text-muted-foreground">
  425. Settings are saved in browser's localStorage
  426. </p>
  427. </div>
  428. </div>
  429. </ScrollArea>
  430. </div>
  431. <ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
  432. </Dialog.Content>
  433. </Dialog.Root>