diff options
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings')
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> | ||
