]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: Architecture and UI improvements (#19596)
authorAleksander Grygier <redacted>
Sat, 14 Feb 2026 08:06:41 +0000 (09:06 +0100)
committerGitHub <redacted>
Sat, 14 Feb 2026 08:06:41 +0000 (09:06 +0100)
79 files changed:
tools/server/public/index.html.gz
tools/server/webui/.storybook/main.ts
tools/server/webui/.storybook/preview.ts
tools/server/webui/docs/flows/settings-flow.md
tools/server/webui/src/lib/components/app/actions/ActionIconsCodeBlock.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte
tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte
tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte
tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte
tools/server/webui/src/lib/components/app/dialogs/DialogCodePreview.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte
tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte
tools/server/webui/src/lib/components/app/forms/KeyValuePairs.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/forms/SearchInput.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/forms/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/index.ts
tools/server/webui/src/lib/components/app/misc/ActionButton.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/DropdownMenuSearchable.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/SearchInput.svelte [deleted file]
tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte [deleted file]
tools/server/webui/src/lib/components/app/models/ModelBadge.svelte
tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
tools/server/webui/src/lib/components/app/models/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte
tools/server/webui/src/lib/constants/binary-detection.ts
tools/server/webui/src/lib/constants/cache.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/input-classes.ts
tools/server/webui/src/lib/constants/settings-sections.ts [new file with mode: 0644]
tools/server/webui/src/lib/enums/index.ts
tools/server/webui/src/lib/enums/ui.ts [new file with mode: 0644]
tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts
tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
tools/server/webui/src/lib/markdown/resolve-attachment-images.ts [new file with mode: 0644]
tools/server/webui/src/lib/services/database.service.ts
tools/server/webui/src/lib/services/database.ts [deleted file]
tools/server/webui/src/lib/services/index.ts
tools/server/webui/src/lib/services/models.service.ts
tools/server/webui/src/lib/services/models.ts [deleted file]
tools/server/webui/src/lib/services/parameter-sync.service.ts
tools/server/webui/src/lib/services/parameter-sync.spec.ts [deleted file]
tools/server/webui/src/lib/services/parameter-sync.ts [deleted file]
tools/server/webui/src/lib/services/props.service.ts
tools/server/webui/src/lib/services/props.ts [deleted file]
tools/server/webui/src/lib/stores/chat.svelte.ts
tools/server/webui/src/lib/stores/conversations.svelte.ts
tools/server/webui/src/lib/stores/models.svelte.ts
tools/server/webui/src/lib/stores/server.svelte.ts
tools/server/webui/src/lib/stores/settings.svelte.ts
tools/server/webui/src/lib/types/chat.d.ts
tools/server/webui/src/lib/types/common.d.ts [new file with mode: 0644]
tools/server/webui/src/lib/types/index.ts
tools/server/webui/src/lib/types/settings.d.ts
tools/server/webui/src/lib/utils/api-fetch.ts
tools/server/webui/src/lib/utils/branching.ts
tools/server/webui/src/lib/utils/cache-ttl.ts
tools/server/webui/src/lib/utils/modality-file-validation.ts
tools/server/webui/src/lib/utils/text-files.ts
tools/server/webui/tests/stories/ChatForm.stories.svelte [deleted file]
tools/server/webui/tests/stories/ChatScreenForm.stories.svelte [new file with mode: 0644]
tools/server/webui/tests/stories/MarkdownContent.stories.svelte
tools/server/webui/vite.config.ts

index f4ff57b4c979075cb73117619c5a02ad2bb89e5e..75fc856f5452ceb57268e4237a2bc9987e76df05 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index bfd16fa224549822d919d7b820116753ed6705dc..4f6945f21080a5be481bc24b3cf89264909a449a 100644 (file)
@@ -1,17 +1,24 @@
 import type { StorybookConfig } from '@storybook/sveltekit';
+import { dirname, resolve } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
 
 const config: StorybookConfig = {
        stories: ['../tests/stories/**/*.mdx', '../tests/stories/**/*.stories.@(js|ts|svelte)'],
        addons: [
                '@storybook/addon-svelte-csf',
                '@chromatic-com/storybook',
-               '@storybook/addon-docs',
+               '@storybook/addon-vitest',
                '@storybook/addon-a11y',
-               '@storybook/addon-vitest'
+               '@storybook/addon-docs'
        ],
-       framework: {
-               name: '@storybook/sveltekit',
-               options: {}
+       framework: '@storybook/sveltekit',
+       viteFinal: async (config) => {
+               config.server = config.server || {};
+               config.server.fs = config.server.fs || {};
+               config.server.fs.allow = [...(config.server.fs.allow || []), resolve(__dirname, '../tests')];
+               return config;
        }
 };
 export default config;
index 8d530e43e374918b8545aabdff87647b7ac1c8a2..566dbfd289c44c726efd6ab552b2be0b17ee419f 100644 (file)
@@ -13,7 +13,7 @@ const preview: Preview = {
                },
 
                backgrounds: {
-                       disable: true
+                       disabled: true
                },
 
                a11y: {
index 474aef01b09e612b0d4eb9909ee27a5317f19a42..40ad3bd94d7503cd06cd6b1d9a04703e5c5ff5e8 100644 (file)
@@ -49,14 +49,20 @@ sequenceDiagram
     settingsStore->>serverStore: defaultParams
     serverStore-->>settingsStore: {temperature, top_p, top_k, ...}
 
-    settingsStore->>ParamSvc: extractServerDefaults(defaultParams)
-    ParamSvc-->>settingsStore: Record<string, value>
+    loop each SYNCABLE_PARAMETER
+        alt key NOT in userOverrides
+            settingsStore->>settingsStore: config[key] = serverDefault[key]
+            Note right of settingsStore: Non-overridden params adopt server default
+        else key in userOverrides
+            Note right of settingsStore: Keep user value, skip server default
+        end
+    end
 
-    settingsStore->>ParamSvc: mergeWithServerDefaults(config, serverDefaults)
-    Note right of ParamSvc: For each syncable parameter:<br/>- If NOT in userOverrides → use server default<br/>- If in userOverrides → keep user value
-    ParamSvc-->>settingsStore: mergedConfig
+    alt serverStore.props has webuiSettings
+        settingsStore->>settingsStore: Apply webuiSettings from server
+        Note right of settingsStore: Server-provided UI settings<br/>(e.g. showRawOutputSwitch)
+    end
 
-    settingsStore->>settingsStore: config = mergedConfig
     settingsStore->>settingsStore: saveConfig()
     deactivate settingsStore
 
@@ -67,11 +73,18 @@ sequenceDiagram
     UI->>settingsStore: updateConfig(key, value)
     activate settingsStore
     settingsStore->>settingsStore: config[key] = value
-    settingsStore->>settingsStore: userOverrides.add(key)
-    Note right of settingsStore: Mark as user-modified (won't be overwritten by server)
+
+    alt value matches server default for key
+        settingsStore->>settingsStore: userOverrides.delete(key)
+        Note right of settingsStore: Matches server default, remove override
+    else value differs from server default
+        settingsStore->>settingsStore: userOverrides.add(key)
+        Note right of settingsStore: Mark as user-modified (won't be overwritten)
+    end
+
     settingsStore->>settingsStore: saveConfig()
-    settingsStore->>LS: set("llama-config", config)
-    settingsStore->>LS: set("llama-userOverrides", [...userOverrides])
+    settingsStore->>LS: set(CONFIG_LOCALSTORAGE_KEY, config)
+    settingsStore->>LS: set(USER_OVERRIDES_LOCALSTORAGE_KEY, [...userOverrides])
     deactivate settingsStore
 
     UI->>settingsStore: updateMultipleConfig({key1: val1, key2: val2})
@@ -88,10 +101,9 @@ sequenceDiagram
 
     UI->>settingsStore: resetConfig()
     activate settingsStore
-    settingsStore->>settingsStore: config = SETTING_CONFIG_DEFAULT
+    settingsStore->>settingsStore: config = {...SETTING_CONFIG_DEFAULT}
     settingsStore->>settingsStore: userOverrides.clear()
-    settingsStore->>settingsStore: syncWithServerDefaults()
-    Note right of settingsStore: Apply server defaults for syncable params
+    Note right of settingsStore: All params reset to defaults<br/>Next syncWithServerDefaults will adopt server values
     settingsStore->>settingsStore: saveConfig()
     deactivate settingsStore
 
index 54ff0af1a0717db778cfff53944355f1381db058..b20e79b5e0b75525973e3bbec5e03daa2fc64cbc 100644 (file)
@@ -1,6 +1,6 @@
 <script lang="ts">
        import { Eye } from '@lucide/svelte';
-       import ActionIconCopyToClipboard from '$lib/components/app/actions/ActionIconCopyToClipboard.svelte';
+       import { ActionIconCopyToClipboard } from '$lib/components/app';
        import { FileTypeText } from '$lib/enums';
 
        interface Props {
index 95645295fb9690886bb75e70243d0a06714bd059..e335f6c546d5aa51cab34dbf1adea1c025c6fd9a 100644 (file)
        let currentConfig = $derived(config());
        let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
        let isRecording = $state(false);
-       let message = $state(initialMessage);
+       let message = $derived(initialMessage);
        let pasteLongTextToFileLength = $derived.by(() => {
                const n = Number(currentConfig.pasteLongTextToFileLen);
                return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
        });
-       let previousIsLoading = $state(isLoading);
-       let previousInitialMessage = $state(initialMessage);
+       let previousIsLoading = $derived(isLoading);
+       let previousInitialMessage = $derived(initialMessage);
        let recordingSupported = $state(false);
        let textareaRef: ChatFormTextarea | undefined = $state(undefined);
 
 
 <form
        onsubmit={handleSubmit}
-       class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
+       class="relative {INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
                ? 'cursor-not-allowed opacity-60'
                : ''} {className}"
        data-slot="chat-form"
        />
 
        <div
-               class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
+               class="flex-column relative min-h-[48px] items-center rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!py-3"
                onpaste={handlePaste}
        >
                <ChatFormTextarea
+                       class="px-5 py-1.5 md:pt-0"
                        bind:this={textareaRef}
                        bind:value={message}
                        onKeydown={handleKeydown}
                />
 
                <ChatFormActions
+                       class="px-3"
                        bind:this={chatFormActionsRef}
                        canSend={message.trim().length > 0 || uploadedFiles.length > 0}
                        hasText={message.trim().length > 0}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte
new file mode 100644 (file)
index 0000000..f8c1b23
--- /dev/null
@@ -0,0 +1,189 @@
+<script lang="ts">
+       import { page } from '$app/state';
+       import { MessageSquare, Plus } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+       import { FILE_TYPE_ICONS } from '$lib/constants/icons';
+       import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
+
+       interface Props {
+               class?: string;
+               disabled?: boolean;
+               hasAudioModality?: boolean;
+               hasVisionModality?: boolean;
+               onFileUpload?: () => void;
+               onSystemPromptClick?: () => void;
+       }
+
+       type AttachmentActionId = 'images' | 'audio' | 'text' | 'pdf' | 'system';
+
+       interface AttachmentAction {
+               id: AttachmentActionId;
+               label: string;
+               disabled?: boolean;
+               disabledReason?: string;
+               tooltip?: string;
+       }
+
+       let {
+               class: className = '',
+               disabled = false,
+               hasAudioModality = false,
+               hasVisionModality = false,
+               onFileUpload,
+               onSystemPromptClick
+       }: Props = $props();
+
+       let isNewChat = $derived(!page.params.id);
+       let systemMessageTooltip = $derived(
+               isNewChat
+                       ? 'Add custom system message for a new conversation'
+                       : 'Inject custom system message at the beginning of the conversation'
+       );
+
+       let actions = $derived.by<AttachmentAction[]>(() => [
+               {
+                       id: 'images',
+                       label: 'Images',
+                       disabled: !hasVisionModality,
+                       disabledReason: !hasVisionModality
+                               ? 'Images require vision models to be processed'
+                               : undefined
+               },
+               {
+                       id: 'audio',
+                       label: 'Audio Files',
+                       disabled: !hasAudioModality,
+                       disabledReason: !hasAudioModality
+                               ? 'Audio files require audio models to be processed'
+                               : undefined
+               },
+               {
+                       id: 'text',
+                       label: 'Text Files'
+               },
+               {
+                       id: 'pdf',
+                       label: 'PDF Files',
+                       tooltip: !hasVisionModality
+                               ? 'PDFs will be converted to text. Image-based PDFs may not work properly.'
+                               : undefined
+               },
+               {
+                       id: 'system',
+                       label: 'System Message',
+                       tooltip: systemMessageTooltip
+               }
+       ]);
+
+       function handleActionClick(id: AttachmentActionId) {
+               if (id === 'system') {
+                       onSystemPromptClick?.();
+                       return;
+               }
+
+               onFileUpload?.();
+       }
+
+       const triggerTooltipText = 'Add files or system message';
+       const itemClass = 'flex cursor-pointer items-center gap-2';
+</script>
+
+<div class="flex items-center gap-1 {className}">
+       <DropdownMenu.Root>
+               <DropdownMenu.Trigger name="Attach files" {disabled}>
+                       <Tooltip.Root>
+                               <Tooltip.Trigger class="w-full">
+                                       <Button
+                                               class="file-upload-button h-8 w-8 rounded-full p-0"
+                                               {disabled}
+                                               variant="secondary"
+                                               type="button"
+                                       >
+                                               <span class="sr-only">{triggerTooltipText}</span>
+
+                                               <Plus class="h-4 w-4" />
+                                       </Button>
+                               </Tooltip.Trigger>
+
+                               <Tooltip.Content>
+                                       <p>{triggerTooltipText}</p>
+                               </Tooltip.Content>
+                       </Tooltip.Root>
+               </DropdownMenu.Trigger>
+
+               <DropdownMenu.Content align="start" class="w-56">
+                       {#each actions as item (item.id)}
+                               {@const hasDisabledTooltip = !!item.disabled && !!item.disabledReason}
+                               {@const hasEnabledTooltip = !item.disabled && !!item.tooltip}
+
+                               {#if hasDisabledTooltip}
+                                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+                                               <Tooltip.Trigger class="w-full">
+                                                       <DropdownMenu.Item class={itemClass} disabled>
+                                                               {#if item.id === 'images'}
+                                                                       <FILE_TYPE_ICONS.image class="h-4 w-4" />
+                                                               {:else if item.id === 'audio'}
+                                                                       <FILE_TYPE_ICONS.audio class="h-4 w-4" />
+                                                               {:else if item.id === 'text'}
+                                                                       <FILE_TYPE_ICONS.text class="h-4 w-4" />
+                                                               {:else if item.id === 'pdf'}
+                                                                       <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
+                                                               {:else}
+                                                                       <MessageSquare class="h-4 w-4" />
+                                                               {/if}
+
+                                                               <span>{item.label}</span>
+                                                       </DropdownMenu.Item>
+                                               </Tooltip.Trigger>
+
+                                               <Tooltip.Content side="right">
+                                                       <p>{item.disabledReason}</p>
+                                               </Tooltip.Content>
+                                       </Tooltip.Root>
+                               {:else if hasEnabledTooltip}
+                                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+                                               <Tooltip.Trigger class="w-full">
+                                                       <DropdownMenu.Item class={itemClass} onclick={() => handleActionClick(item.id)}>
+                                                               {#if item.id === 'images'}
+                                                                       <FILE_TYPE_ICONS.image class="h-4 w-4" />
+                                                               {:else if item.id === 'audio'}
+                                                                       <FILE_TYPE_ICONS.audio class="h-4 w-4" />
+                                                               {:else if item.id === 'text'}
+                                                                       <FILE_TYPE_ICONS.text class="h-4 w-4" />
+                                                               {:else if item.id === 'pdf'}
+                                                                       <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
+                                                               {:else}
+                                                                       <MessageSquare class="h-4 w-4" />
+                                                               {/if}
+
+                                                               <span>{item.label}</span>
+                                                       </DropdownMenu.Item>
+                                               </Tooltip.Trigger>
+
+                                               <Tooltip.Content side="right">
+                                                       <p>{item.tooltip}</p>
+                                               </Tooltip.Content>
+                                       </Tooltip.Root>
+                               {:else}
+                                       <DropdownMenu.Item class={itemClass} onclick={() => handleActionClick(item.id)}>
+                                               {#if item.id === 'images'}
+                                                       <FILE_TYPE_ICONS.image class="h-4 w-4" />
+                                               {:else if item.id === 'audio'}
+                                                       <FILE_TYPE_ICONS.audio class="h-4 w-4" />
+                                               {:else if item.id === 'text'}
+                                                       <FILE_TYPE_ICONS.text class="h-4 w-4" />
+                                               {:else if item.id === 'pdf'}
+                                                       <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
+                                               {:else}
+                                                       <MessageSquare class="h-4 w-4" />
+                                               {/if}
+
+                                               <span>{item.label}</span>
+                                       </DropdownMenu.Item>
+                               {/if}
+                       {/each}
+               </DropdownMenu.Content>
+       </DropdownMenu.Root>
+</div>
index c621a69e05050a721ae8cefc423dddc7c8f7382c..cf5aca42a1ff89ee0ef51abc558dfe7217fdfd95 100644 (file)
@@ -2,7 +2,7 @@
        import { Square } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import {
-               ChatFormActionFileAttachments,
+               ChatFormActionAttachmentsDropdown,
                ChatFormActionRecord,
                ChatFormActionSubmit,
                ModelsSelector
 
        const { handleModelChange } = useModelChangeValidation({
                getRequiredModalities: () => usedModalities(),
-               onValidationFailure: async (previousModelId) => {
+               onValidationFailure: async (previousModelId: string | null) => {
                        if (previousModelId) {
                                await modelsStore.selectModelById(previousModelId);
                        }
 </script>
 
 <div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
-       <ChatFormActionFileAttachments
-               class="mr-auto"
-               {disabled}
-               {hasAudioModality}
-               {hasVisionModality}
-               {onFileUpload}
-               {onSystemPromptClick}
-       />
-
-       <ModelsSelector
-               {disabled}
-               bind:this={selectorModelRef}
-               currentModel={conversationModel}
-               forceForegroundText={true}
-               useGlobalSelection={true}
-               onModelChange={handleModelChange}
-       />
+       <div class="mr-auto flex items-center gap-2">
+               <ChatFormActionAttachmentsDropdown
+                       {disabled}
+                       {hasAudioModality}
+                       {hasVisionModality}
+                       {onFileUpload}
+                       {onSystemPromptClick}
+               />
+       </div>
+
+       <div class="ml-auto flex items-center gap-1.5">
+               <ModelsSelector
+                       {disabled}
+                       bind:this={selectorModelRef}
+                       currentModel={conversationModel}
+                       forceForegroundText={true}
+                       useGlobalSelection={true}
+                       onModelChange={handleModelChange}
+               />
+       </div>
 
        {#if isLoading}
                <Button
                        type="button"
+                       variant="secondary"
                        onclick={onStop}
-                       class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
+                       class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
                >
                        <span class="sr-only">Stop</span>
-                       <Square class="h-8 w-8 fill-destructive stroke-destructive" />
+
+                       <Square
+                               class="h-8 w-8 fill-muted-foreground stroke-muted-foreground group-hover:fill-destructive group-hover:stroke-destructive hover:fill-destructive hover:stroke-destructive"
+                       />
                </Button>
        {:else if shouldShowRecordButton}
                <ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
index 3470e2f711d583b417f5032df4f4224b460f1b3e..25895c83b7f35b5063d0d9984431fe7b7a196076 100644 (file)
@@ -62,8 +62,8 @@
                assistantMessages: number;
                messageTypes: string[];
        } | null>(null);
-       let editedContent = $state(message.content);
-       let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
+       let editedContent = $derived(message.content);
+       let editedExtras = $derived<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
        let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
        let isEditing = $state(false);
        let showDeleteDialog = $state(false);
index 1cb6b274b67f18d434e2c22ba54ce3030db1cefd..867def5fc3c85c452fe0a0fe8d7c6de72739c4bc 100644 (file)
 
        const { handleModelChange } = useModelChangeValidation({
                getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
-               onSuccess: (modelName) => onRegenerate(modelName)
+               onSuccess: (modelName: string) => onRegenerate(modelName)
        });
 
        function handleCopyModel() {
index f812ea2fd9d406cd0888bdf480bb3c246e253055..c216ea690b1f98e20bbf6b24dd288129b9f7acc8 100644 (file)
 
        const { handleModelChange } = useModelChangeValidation({
                getRequiredModalities,
-               onValidationFailure: async (previousModelId) => {
+               onValidationFailure: async (previousModelId: string | null) => {
                        if (previousModelId) {
                                await modelsStore.selectModelById(previousModelId);
                        }
index d457e042fcbcf068d69380ae23d16b3fafce8f8f..b53e82aaf9c40ba232c6073fac4974245d097e70 100644 (file)
@@ -28,7 +28,7 @@
                initialView = ChatMessageStatsView.GENERATION
        }: Props = $props();
 
-       let activeView: ChatMessageStatsView = $state(initialView);
+       let activeView: ChatMessageStatsView = $derived(initialView);
        let hasAutoSwitchedToGeneration = $state(false);
 
        // In live mode: auto-switch to GENERATION tab when prompt processing completes
index a5450e6af894be1dafcff8bff095121820ea8f55..3d432e26bc774e50243cb63d4e4a8250c3d75ff5 100644 (file)
@@ -35,6 +35,7 @@
        import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
        import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
        import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
+       import { ErrorDialogType } from '$lib/enums';
        import { onMount } from 'svelte';
        import { fade, fly, slide } from 'svelte/transition';
        import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
        contextInfo={activeErrorDialog?.contextInfo}
        onOpenChange={handleErrorDialogOpenChange}
        open={Boolean(activeErrorDialog)}
-       type={activeErrorDialog?.type ?? 'server'}
+       type={(activeErrorDialog?.type as ErrorDialogType) ?? ErrorDialogType.SERVER}
 />
 
 <style>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
new file mode 100644 (file)
index 0000000..6a0c913
--- /dev/null
@@ -0,0 +1,47 @@
+<script lang="ts">
+       import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
+
+       interface Props {
+               class?: string;
+               disabled?: boolean;
+               initialMessage?: string;
+               isLoading?: boolean;
+               onFileRemove?: (fileId: string) => void;
+               onFileUpload?: (files: File[]) => void;
+               onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
+               onStop?: () => void;
+               onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
+               showHelperText?: boolean;
+               uploadedFiles?: ChatUploadedFile[];
+       }
+
+       let {
+               class: className,
+               disabled = false,
+               initialMessage = '',
+               isLoading = false,
+               onFileRemove,
+               onFileUpload,
+               onSend,
+               onStop,
+               onSystemPromptAdd,
+               showHelperText = true,
+               uploadedFiles = $bindable([])
+       }: Props = $props();
+</script>
+
+<div class="relative mx-auto max-w-[48rem]">
+       <ChatForm
+               class={className}
+               {disabled}
+               {initialMessage}
+               {isLoading}
+               {onFileRemove}
+               {onFileUpload}
+               {onSend}
+               {onStop}
+               {onSystemPromptAdd}
+               {showHelperText}
+               bind:uploadedFiles
+       />
+</div>
index 967f19bbce53f1eb82481e94215dbc88b26ba2ff..16940c16f50f8cb33bcc749ba7bca075dd995b6c 100644 (file)
        } from '$lib/components/app';
        import { ScrollArea } from '$lib/components/ui/scroll-area';
        import { config, settingsStore } from '$lib/stores/settings.svelte';
+       import {
+               SETTINGS_SECTION_TITLES,
+               type SettingsSectionTitle
+       } from '$lib/constants/settings-sections';
        import { setMode } from 'mode-watcher';
        import type { Component } from 'svelte';
 
        interface Props {
                onSave?: () => void;
+               initialSection?: SettingsSectionTitle;
        }
 
-       let { onSave }: Props = $props();
+       let { onSave, initialSection }: Props = $props();
 
        const settingSections: Array<{
                fields: SettingsFieldConfig[];
                icon: Component;
-               title: string;
+               title: SettingsSectionTitle;
        }> = [
                {
                        title: 'General',
                // }
        ];
 
-       let activeSection = $state('General');
+       let activeSection = $derived<SettingsSectionTitle>(
+               initialSection ?? SETTINGS_SECTION_TITLES.GENERAL
+       );
        let currentSection = $derived(
                settingSections.find((section) => section.title === activeSection) || settingSections[0]
        );
        let canScrollRight = $state(false);
        let scrollContainer: HTMLDivElement | undefined = $state();
 
+       $effect(() => {
+               if (!initialSection) {
+                       return;
+               }
+
+               if (settingSections.some((section) => section.title === initialSection)) {
+                       activeSection = initialSection;
+               }
+       });
+
        function handleThemeChange(newTheme: string) {
                localConfig.theme = newTheme;
 
index bf2fa4f9e98d8c4f7f7707638e45b59f5d48a55b..d9c71dba8ee21b8e2c5e99238479c1fab35be8a0 100644 (file)
                                        {
                                                icon: Download,
                                                label: 'Export',
-                                               onclick: (e) => {
+                                               onclick: (e: Event) => {
                                                        e.stopPropagation();
                                                        conversationsStore.downloadConversation(conversation.id);
                                                },
index ef6c7e064f04c03746141783b40092bb1d7febb7..022a1a45bb8f870f2e6018de312b1c8c32073403 100644 (file)
@@ -15,6 +15,7 @@
        import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
        import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
        import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
+       import { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images';
        import { remarkLiteralHtml } from '$lib/markdown/literal-html';
        import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
        import {
@@ -23,6 +24,7 @@
                DATA_ERROR_HANDLED_ATTR,
                BOOL_TRUE_STRING
        } from '$lib/constants/markdown';
+       import { UrlPrefix } from '$lib/enums';
        import { FileTypeText } from '$lib/enums/files';
        import {
                highlightCode,
@@ -33,8 +35,7 @@
        import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
        import githubLightCss from 'highlight.js/styles/github.css?inline';
        import { mode } from 'mode-watcher';
-       import ActionIconsCodeBlock from '$lib/components/app/actions/ActionIconsCodeBlock.svelte';
-       import DialogCodePreview from '$lib/components/app/misc/CodePreviewDialog.svelte';
+       import { ActionIconsCodeBlock, DialogCodePreview } from '$lib/components/app';
        import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
        import type { DatabaseMessageExtra } from '$lib/types/database';
 
                        .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
                        .use(rehypeEnhanceLinks) // Add target="_blank" to links
                        .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
+                       .use(rehypeResolveAttachmentImages, { attachments })
                        .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
        });
 
                if (!img || !img.src) return;
 
                // Don't handle data URLs or already-handled images
-               if (img.src.startsWith('data:') || img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING)
+               if (
+                       img.src.startsWith(UrlPrefix.DATA) ||
+                       img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
+               )
                        return;
                img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
 
index b4340e83e5817b76f427b090310835416af0ade7..54a3c90d3c0c1b10382559b5e5e7900066bc8a4d 100644 (file)
@@ -1,10 +1,11 @@
 <script lang="ts">
        import * as AlertDialog from '$lib/components/ui/alert-dialog';
        import { AlertTriangle, TimerOff } from '@lucide/svelte';
+       import { ErrorDialogType } from '$lib/enums';
 
        interface Props {
                open: boolean;
-               type: 'timeout' | 'server';
+               type: ErrorDialogType;
                message: string;
                contextInfo?: { n_prompt_tokens: number; n_ctx: number };
                onOpenChange?: (open: boolean) => void;
@@ -12,7 +13,7 @@
 
        let { open = $bindable(), type, message, contextInfo, onOpenChange }: Props = $props();
 
-       const isTimeout = $derived(type === 'timeout');
+       const isTimeout = $derived(type === ErrorDialogType.TIMEOUT);
        const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
        const description = $derived(
                isTimeout
                                                <span class="font-medium">Prompt tokens:</span>
                                                {contextInfo.n_prompt_tokens.toLocaleString()}
                                        </p>
-                                       <p><span class="font-medium">Context size:</span> {contextInfo.n_ctx.toLocaleString()}</p>
+                                       {#if contextInfo.n_ctx}
+                                               <p>
+                                                       <span class="font-medium">Context size:</span>
+                                                       {contextInfo.n_ctx.toLocaleString()}
+                                               </p>
+                                       {/if}
                                </div>
                        {/if}
                </div>
index e9aaa1000b32e225159205e537fa8d9138c08348..7b1e598ce7237bf16ebdc8e9b3ee824ac07fc390 100644 (file)
@@ -1,13 +1,15 @@
 <script lang="ts">
        import * as Dialog from '$lib/components/ui/dialog';
        import { ChatSettings } from '$lib/components/app';
+       import type { SettingsSectionTitle } from '$lib/constants/settings-sections';
 
        interface Props {
                onOpenChange?: (open: boolean) => void;
                open?: boolean;
+               initialSection?: SettingsSectionTitle;
        }
 
-       let { onOpenChange, open = false }: Props = $props();
+       let { onOpenChange, open = false, initialSection }: Props = $props();
 
        let chatSettingsRef: ChatSettings | undefined = $state();
 
 
 <Dialog.Root {open} onOpenChange={handleClose}>
        <Dialog.Content
-               class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
-                       md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
-               style="max-width: 48rem;"
+               class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] max-w-4xl! flex-col gap-0 rounded-none
+                       p-0 md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
        >
-               <ChatSettings bind:this={chatSettingsRef} onSave={handleSave} />
+               <ChatSettings bind:this={chatSettingsRef} onSave={handleSave} {initialSection} />
        </Dialog.Content>
 </Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogCodePreview.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogCodePreview.svelte
new file mode 100644 (file)
index 0000000..f339a26
--- /dev/null
@@ -0,0 +1,93 @@
+<script lang="ts">
+       import { Dialog as DialogPrimitive } from 'bits-ui';
+       import XIcon from '@lucide/svelte/icons/x';
+
+       interface Props {
+               open: boolean;
+               code: string;
+               language: string;
+               onOpenChange?: (open: boolean) => void;
+       }
+
+       let { open = $bindable(), code, language, onOpenChange }: Props = $props();
+
+       let iframeRef = $state<HTMLIFrameElement | null>(null);
+
+       $effect(() => {
+               if (!iframeRef) return;
+
+               if (open) {
+                       iframeRef.srcdoc = code;
+               } else {
+                       iframeRef.srcdoc = '';
+               }
+       });
+
+       function handleOpenChange(nextOpen: boolean) {
+               open = nextOpen;
+               onOpenChange?.(nextOpen);
+       }
+</script>
+
+<DialogPrimitive.Root {open} onOpenChange={handleOpenChange}>
+       <DialogPrimitive.Portal>
+               <DialogPrimitive.Overlay class="code-preview-overlay" />
+
+               <DialogPrimitive.Content class="code-preview-content">
+                       <iframe
+                               bind:this={iframeRef}
+                               title="Preview {language}"
+                               sandbox="allow-scripts allow-same-origin"
+                               class="code-preview-iframe"
+                       ></iframe>
+
+                       <DialogPrimitive.Close
+                               class="code-preview-close absolute top-4 right-4 border-none bg-transparent text-white opacity-70 mix-blend-difference transition-opacity hover:opacity-100 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-8"
+                               aria-label="Close preview"
+                       >
+                               <XIcon />
+                               <span class="sr-only">Close preview</span>
+                       </DialogPrimitive.Close>
+               </DialogPrimitive.Content>
+       </DialogPrimitive.Portal>
+</DialogPrimitive.Root>
+
+<style lang="postcss">
+       :global(.code-preview-overlay) {
+               position: fixed;
+               inset: 0;
+               background-color: transparent;
+               z-index: 100000;
+       }
+
+       :global(.code-preview-content) {
+               position: fixed;
+               inset: 0;
+               top: 0 !important;
+               left: 0 !important;
+               width: 100dvw;
+               height: 100dvh;
+               margin: 0;
+               padding: 0;
+               border: none;
+               border-radius: 0;
+               background-color: transparent;
+               box-shadow: none;
+               display: block;
+               overflow: hidden;
+               transform: none !important;
+               z-index: 100001;
+       }
+
+       :global(.code-preview-iframe) {
+               display: block;
+               width: 100dvw;
+               height: 100dvh;
+               border: 0;
+       }
+
+       :global(.code-preview-close) {
+               position: absolute;
+               z-index: 100002;
+       }
+</style>
index b5175a9925ff1bb4aea0649852fe53fe9812622f..d8aa66f3e81a7fd677f3425df222a6cdf731f437 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import * as AlertDialog from '$lib/components/ui/alert-dialog';
        import type { Component } from 'svelte';
+       import { KeyboardKey } from '$lib/enums';
 
        interface Props {
                open: boolean;
@@ -29,7 +30,7 @@
        }: Props = $props();
 
        function handleKeydown(event: KeyboardEvent) {
-               if (event.key === 'Enter') {
+               if (event.key === KeyboardKey.ENTER) {
                        event.preventDefault();
                        onConfirm();
                }
index dfea47cc90b5583e45e5cd9fdc1a059e67473351..eac83f234ddd73d775ab5aa874357b7c48753e3c 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts">
        import * as Dialog from '$lib/components/ui/dialog';
        import * as Table from '$lib/components/ui/table';
-       import { BadgeModality, CopyToClipboardIcon } from '$lib/components/app';
+       import { BadgeModality, ActionIconCopyToClipboard } from '$lib/components/app';
        import { serverStore } from '$lib/stores/server.svelte';
        import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
        import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
@@ -47,6 +47,7 @@
 
                <Dialog.Header>
                        <Dialog.Title>Model Information</Dialog.Title>
+
                        <Dialog.Description>Current model details and capabilities</Dialog.Description>
                </Dialog.Header>
 
@@ -73,7 +74,7 @@
                                                                                        {modelName}
                                                                                </span>
 
-                                                                               <CopyToClipboardIcon
+                                                                               <ActionIconCopyToClipboard
                                                                                        text={modelName || ''}
                                                                                        canCopy={!!modelName}
                                                                                        ariaLabel="Copy model name to clipboard"
@@ -97,7 +98,7 @@
                                                                                {serverProps.model_path}
                                                                        </span>
 
-                                                                       <CopyToClipboardIcon
+                                                                       <ActionIconCopyToClipboard
                                                                                text={serverProps.model_path}
                                                                                ariaLabel="Copy model path to clipboard"
                                                                        />
                                                        </Table.Row>
 
                                                        <!-- Context Size -->
-                                                       <Table.Row>
-                                                               <Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
-                                                               <Table.Cell
-                                                                       >{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
-                                                               >
-                                                       </Table.Row>
+                                                       {#if serverProps?.default_generation_settings?.n_ctx}
+                                                               <Table.Row>
+                                                                       <Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
+
+                                                                       <Table.Cell
+                                                                               >{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
+                                                                       >
+                                                               </Table.Row>
+                                                       {:else}
+                                                               <Table.Row>
+                                                                       <Table.Cell class="h-10 align-middle font-medium text-red-500"
+                                                                               >Context Size</Table.Cell
+                                                                       >
+
+                                                                       <Table.Cell class="text-red-500">Not available</Table.Cell>
+                                                               </Table.Row>
+                                                       {/if}
 
                                                        <!-- Training Context -->
                                                        {#if modelMeta?.n_ctx_train}
                                                                <Table.Row>
                                                                        <Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
+
                                                                        <Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
                                                                </Table.Row>
                                                        {/if}
                                                        {#if modelMeta?.size}
                                                                <Table.Row>
                                                                        <Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
+
                                                                        <Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
                                                                </Table.Row>
                                                        {/if}
                                                        {#if modelMeta?.n_params}
                                                                <Table.Row>
                                                                        <Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
+
                                                                        <Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
                                                                </Table.Row>
                                                        {/if}
                                                        {#if modelMeta?.n_embd}
                                                                <Table.Row>
                                                                        <Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
+
                                                                        <Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
                                                                </Table.Row>
                                                        {/if}
                                                        {#if modelMeta?.n_vocab}
                                                                <Table.Row>
                                                                        <Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
+
                                                                        <Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
                                                                </Table.Row>
                                                        {/if}
                                                        <!-- Total Slots -->
                                                        <Table.Row>
                                                                <Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
+
                                                                <Table.Cell>{serverProps.total_slots}</Table.Cell>
                                                        </Table.Row>
 
                                                        {#if modalities.length > 0}
                                                                <Table.Row>
                                                                        <Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
+
                                                                        <Table.Cell>
                                                                                <div class="flex flex-wrap gap-1">
                                                                                        <BadgeModality {modalities} />
                                                        <!-- Build Info -->
                                                        <Table.Row>
                                                                <Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
+
                                                                <Table.Cell class="align-middle font-mono text-xs"
                                                                        >{serverProps.build_info}</Table.Cell
                                                                >
                                                        {#if serverProps.chat_template}
                                                                <Table.Row>
                                                                        <Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
+
                                                                        <Table.Cell class="py-10">
                                                                                <div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4">
                                                                                        <pre
diff --git a/tools/server/webui/src/lib/components/app/forms/KeyValuePairs.svelte b/tools/server/webui/src/lib/components/app/forms/KeyValuePairs.svelte
new file mode 100644 (file)
index 0000000..ca3da02
--- /dev/null
@@ -0,0 +1,110 @@
+<script lang="ts">
+       import { Plus, Trash2 } from '@lucide/svelte';
+       import { Input } from '$lib/components/ui/input';
+       import { autoResizeTextarea } from '$lib/utils';
+       import type { KeyValuePair } from '$lib/types';
+
+       interface Props {
+               class?: string;
+               pairs: KeyValuePair[];
+               onPairsChange: (pairs: KeyValuePair[]) => void;
+               keyPlaceholder?: string;
+               valuePlaceholder?: string;
+               addButtonLabel?: string;
+               emptyMessage?: string;
+               sectionLabel?: string;
+               sectionLabelOptional?: boolean;
+       }
+
+       let {
+               class: className = '',
+               pairs,
+               onPairsChange,
+               keyPlaceholder = 'Key',
+               valuePlaceholder = 'Value',
+               addButtonLabel = 'Add',
+               emptyMessage = 'No items configured.',
+               sectionLabel,
+               sectionLabelOptional = true
+       }: Props = $props();
+
+       function addPair() {
+               onPairsChange([...pairs, { key: '', value: '' }]);
+       }
+
+       function removePair(index: number) {
+               onPairsChange(pairs.filter((_, i) => i !== index));
+       }
+
+       function updatePairKey(index: number, key: string) {
+               const newPairs = [...pairs];
+               newPairs[index] = { ...newPairs[index], key };
+               onPairsChange(newPairs);
+       }
+
+       function updatePairValue(index: number, value: string) {
+               const newPairs = [...pairs];
+               newPairs[index] = { ...newPairs[index], value };
+               onPairsChange(newPairs);
+       }
+</script>
+
+<div class={className}>
+       <div class="mb-2 flex items-center justify-between">
+               {#if sectionLabel}
+                       <span class="text-xs font-medium">
+                               {sectionLabel}
+                               {#if sectionLabelOptional}
+                                       <span class="text-muted-foreground">(optional)</span>
+                               {/if}
+                       </span>
+               {/if}
+
+               <button
+                       type="button"
+                       class="inline-flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
+                       onclick={addPair}
+               >
+                       <Plus class="h-3 w-3" />
+                       {addButtonLabel}
+               </button>
+       </div>
+       {#if pairs.length > 0}
+               <div class="space-y-3">
+                       {#each pairs as pair, index (index)}
+                               <div class="flex items-start gap-2">
+                                       <Input
+                                               type="text"
+                                               placeholder={keyPlaceholder}
+                                               value={pair.key}
+                                               oninput={(e) => updatePairKey(index, e.currentTarget.value)}
+                                               class="flex-1"
+                                       />
+
+                                       <textarea
+                                               use:autoResizeTextarea
+                                               placeholder={valuePlaceholder}
+                                               value={pair.value}
+                                               oninput={(e) => {
+                                                       updatePairValue(index, e.currentTarget.value);
+                                                       autoResizeTextarea(e.currentTarget);
+                                               }}
+                                               class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
+                                               rows="1"
+                                       ></textarea>
+
+                                       <button
+                                               type="button"
+                                               class="mt-1.5 shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
+                                               onclick={() => removePair(index)}
+                                               aria-label="Remove item"
+                                       >
+                                               <Trash2 class="h-3.5 w-3.5" />
+                                       </button>
+                               </div>
+                       {/each}
+               </div>
+       {:else}
+               <p class="text-xs text-muted-foreground">{emptyMessage}</p>
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/forms/SearchInput.svelte b/tools/server/webui/src/lib/components/app/forms/SearchInput.svelte
new file mode 100644 (file)
index 0000000..9a8088d
--- /dev/null
@@ -0,0 +1,73 @@
+<script lang="ts">
+       import { Input } from '$lib/components/ui/input';
+       import { Search, X } from '@lucide/svelte';
+
+       interface Props {
+               value?: string;
+               placeholder?: string;
+               onInput?: (value: string) => void;
+               onClose?: () => void;
+               onKeyDown?: (event: KeyboardEvent) => void;
+               class?: string;
+               id?: string;
+               ref?: HTMLInputElement | null;
+       }
+
+       let {
+               value = $bindable(''),
+               placeholder = 'Search...',
+               onInput,
+               onClose,
+               onKeyDown,
+               class: className,
+               id,
+               ref = $bindable(null)
+       }: Props = $props();
+
+       let showClearButton = $derived(!!value || !!onClose);
+
+       function handleInput(event: Event) {
+               const target = event.target as HTMLInputElement;
+
+               value = target.value;
+               onInput?.(target.value);
+       }
+
+       function handleClear() {
+               if (value) {
+                       value = '';
+                       onInput?.('');
+                       ref?.focus();
+               } else {
+                       onClose?.();
+               }
+       }
+</script>
+
+<div class="relative {className}">
+       <Search
+               class="absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
+       />
+
+       <Input
+               {id}
+               bind:value
+               bind:ref
+               class="pl-9 {showClearButton ? 'pr-9' : ''}"
+               oninput={handleInput}
+               onkeydown={onKeyDown}
+               {placeholder}
+               type="search"
+       />
+
+       {#if showClearButton}
+               <button
+                       type="button"
+                       class="absolute top-1/2 right-3 -translate-y-1/2 transform text-muted-foreground transition-colors hover:text-foreground"
+                       onclick={handleClear}
+                       aria-label={value ? 'Clear search' : 'Close'}
+               >
+                       <X class="h-4 w-4" />
+               </button>
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/forms/index.ts b/tools/server/webui/src/lib/components/app/forms/index.ts
new file mode 100644 (file)
index 0000000..b0280a2
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ *
+ * FORMS & INPUTS
+ *
+ * Form-related utility components.
+ *
+ */
+
+/**
+ * **SearchInput** - Search field with clear button
+ *
+ * Input field optimized for search with clear button and keyboard handling.
+ * Supports placeholder, autofocus, and change callbacks.
+ */
+export { default as SearchInput } from './SearchInput.svelte';
+
+/**
+ * **KeyValuePairs** - Editable key-value list
+ *
+ * Dynamic list of key-value pairs with add/remove functionality.
+ * Used for HTTP headers, metadata, and configuration.
+ *
+ * **Features:**
+ * - Add new pairs with button
+ * - Remove individual pairs
+ * - Customizable placeholders and labels
+ * - Empty state message
+ * - Auto-resize value textarea
+ */
+export { default as KeyValuePairs } from './KeyValuePairs.svelte';
index 8631d4fb3bd4c3040fc93bac457bf3c8a98cb87f..142622ef0a27d25fb87f9d0293bb53fb657545e8 100644 (file)
@@ -1,12 +1,20 @@
-// Chat
+export * from './actions';
+export * from './badges';
+export * from './content';
+export * from './forms';
+export * from './misc';
+export * from './models';
+export * from './navigation';
+export * from './server';
 
+// Chat
 export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
 export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
 export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
 export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
 export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
-
 export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
+export { default as ChatFormActionAttachmentsDropdown } from './chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
 export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
 export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
 export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
@@ -14,36 +22,38 @@ export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions
 export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
 export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
 export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
-
 export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
 export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
+export { default as ChatMessageAssistant } from './chat/ChatMessages/ChatMessageAssistant.svelte';
 export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
+export { default as ChatMessageEditForm } from './chat/ChatMessages/ChatMessageEditForm.svelte';
 export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
 export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
 export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
+export { default as ChatMessageUser } from './chat/ChatMessages/ChatMessageUser.svelte';
 export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
 export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
-
 export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
+export { default as ChatScreenDragOverlay } from './chat/ChatScreen/ChatScreenDragOverlay.svelte';
+export { default as ChatScreenForm } from './chat/ChatScreen/ChatScreenForm.svelte';
 export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
 export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
-
 export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
 export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
 export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
 export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
 export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
-
 export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
+export { default as ChatSidebarActions } from './chat/ChatSidebar/ChatSidebarActions.svelte';
 export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
 export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
 
 // Dialogs
-
 export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
 export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
 export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
 export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte';
+export { default as DialogCodePreview } from './dialogs/DialogCodePreview.svelte';
 export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
 export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
 export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
@@ -51,25 +61,8 @@ export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.
 export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
 export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
 
-// Miscellanous
-
-export { default as ActionButton } from './misc/ActionButton.svelte';
-export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
-export { default as BadgeChatStatistic } from './misc/BadgeChatStatistic.svelte';
-export { default as BadgeInfo } from './misc/BadgeInfo.svelte';
-export { default as ModelBadge } from './models/ModelBadge.svelte';
-export { default as BadgeModality } from './misc/BadgeModality.svelte';
-export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
-export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelte';
-export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
-export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
-export { default as RemoveButton } from './misc/RemoveButton.svelte';
-export { default as SearchInput } from './misc/SearchInput.svelte';
-export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
-export { default as ModelsSelector } from './models/ModelsSelector.svelte';
-
-// Server
-
-export { default as ServerStatus } from './server/ServerStatus.svelte';
-export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
-export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
+// Compatibility aliases
+export { default as ActionButton } from './actions/ActionIcon.svelte';
+export { default as ActionDropdown } from './navigation/DropdownMenuActions.svelte';
+export { default as CopyToClipboardIcon } from './actions/ActionIconCopyToClipboard.svelte';
+export { default as RemoveButton } from './actions/ActionIconRemove.svelte';
diff --git a/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte b/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte
deleted file mode 100644 (file)
index 411a8b6..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-<script lang="ts">
-       import { Button } from '$lib/components/ui/button';
-       import * as Tooltip from '$lib/components/ui/tooltip';
-       import type { Component } from 'svelte';
-
-       interface Props {
-               icon: Component;
-               tooltip: string;
-               variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
-               size?: 'default' | 'sm' | 'lg' | 'icon';
-               class?: string;
-               disabled?: boolean;
-               onclick: () => void;
-               'aria-label'?: string;
-       }
-
-       let {
-               icon,
-               tooltip,
-               variant = 'ghost',
-               size = 'sm',
-               class: className = '',
-               disabled = false,
-               onclick,
-               'aria-label': ariaLabel
-       }: Props = $props();
-</script>
-
-<Tooltip.Root>
-       <Tooltip.Trigger>
-               <Button
-                       {variant}
-                       {size}
-                       {disabled}
-                       {onclick}
-                       class="h-6 w-6 p-0 {className} flex"
-                       aria-label={ariaLabel || tooltip}
-               >
-                       {@const IconComponent = icon}
-                       <IconComponent class="h-3 w-3" />
-               </Button>
-       </Tooltip.Trigger>
-
-       <Tooltip.Content>
-               <p>{tooltip}</p>
-       </Tooltip.Content>
-</Tooltip.Root>
diff --git a/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte b/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte
deleted file mode 100644 (file)
index 83d856d..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-<script lang="ts">
-       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
-       import * as Tooltip from '$lib/components/ui/tooltip';
-       import { KeyboardShortcutInfo } from '$lib/components/app';
-       import type { Component } from 'svelte';
-
-       interface ActionItem {
-               icon: Component;
-               label: string;
-               onclick: (event: Event) => void;
-               variant?: 'default' | 'destructive';
-               disabled?: boolean;
-               shortcut?: string[];
-               separator?: boolean;
-       }
-
-       interface Props {
-               triggerIcon: Component;
-               triggerTooltip?: string;
-               triggerClass?: string;
-               actions: ActionItem[];
-               align?: 'start' | 'center' | 'end';
-               open?: boolean;
-       }
-
-       let {
-               triggerIcon,
-               triggerTooltip,
-               triggerClass = '',
-               actions,
-               align = 'end',
-               open = $bindable(false)
-       }: Props = $props();
-</script>
-
-<DropdownMenu.Root bind:open>
-       <DropdownMenu.Trigger
-               class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
-               onclick={(e) => e.stopPropagation()}
-       >
-               {#if triggerTooltip}
-                       <Tooltip.Root>
-                               <Tooltip.Trigger>
-                                       {@render iconComponent(triggerIcon, 'h-3 w-3')}
-                                       <span class="sr-only">{triggerTooltip}</span>
-                               </Tooltip.Trigger>
-                               <Tooltip.Content>
-                                       <p>{triggerTooltip}</p>
-                               </Tooltip.Content>
-                       </Tooltip.Root>
-               {:else}
-                       {@render iconComponent(triggerIcon, 'h-3 w-3')}
-               {/if}
-       </DropdownMenu.Trigger>
-
-       <DropdownMenu.Content {align} class="z-[999999] w-48">
-               {#each actions as action, index (action.label)}
-                       {#if action.separator && index > 0}
-                               <DropdownMenu.Separator />
-                       {/if}
-
-                       <DropdownMenu.Item
-                               onclick={action.onclick}
-                               variant={action.variant}
-                               disabled={action.disabled}
-                               class="flex items-center justify-between hover:[&>kbd]:opacity-100"
-                       >
-                               <div class="flex items-center gap-2">
-                                       {@render iconComponent(
-                                               action.icon,
-                                               `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
-                                       )}
-                                       {action.label}
-                               </div>
-
-                               {#if action.shortcut}
-                                       <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
-                               {/if}
-                       </DropdownMenu.Item>
-               {/each}
-       </DropdownMenu.Content>
-</DropdownMenu.Root>
-
-{#snippet iconComponent(IconComponent: Component, className: string)}
-       <IconComponent class={className} />
-{/snippet}
diff --git a/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte b/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte
deleted file mode 100644 (file)
index a2b28d2..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-<script lang="ts">
-       import { BadgeInfo } from '$lib/components/app';
-       import * as Tooltip from '$lib/components/ui/tooltip';
-       import { copyToClipboard } from '$lib/utils';
-       import type { Component } from 'svelte';
-
-       interface Props {
-               class?: string;
-               icon: Component;
-               value: string | number;
-               tooltipLabel?: string;
-       }
-
-       let { class: className = '', icon: Icon, value, tooltipLabel }: Props = $props();
-
-       function handleClick() {
-               void copyToClipboard(String(value));
-       }
-</script>
-
-{#if tooltipLabel}
-       <Tooltip.Root>
-               <Tooltip.Trigger>
-                       <BadgeInfo class={className} onclick={handleClick}>
-                               {#snippet icon()}
-                                       <Icon class="h-3 w-3" />
-                               {/snippet}
-
-                               {value}
-                       </BadgeInfo>
-               </Tooltip.Trigger>
-               <Tooltip.Content>
-                       <p>{tooltipLabel}</p>
-               </Tooltip.Content>
-       </Tooltip.Root>
-{:else}
-       <BadgeInfo class={className} onclick={handleClick}>
-               {#snippet icon()}
-                       <Icon class="h-3 w-3" />
-               {/snippet}
-
-               {value}
-       </BadgeInfo>
-{/if}
diff --git a/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte b/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte
deleted file mode 100644 (file)
index c70af6f..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<script lang="ts">
-       import { cn } from '$lib/components/ui/utils';
-       import type { Snippet } from 'svelte';
-
-       interface Props {
-               children: Snippet;
-               class?: string;
-               icon?: Snippet;
-               onclick?: () => void;
-       }
-
-       let { children, class: className = '', icon, onclick }: Props = $props();
-</script>
-
-<button
-       class={cn(
-               'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
-               className
-       )}
-       {onclick}
->
-       {#if icon}
-               {@render icon()}
-       {/if}
-
-       {@render children()}
-</button>
diff --git a/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte b/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte
deleted file mode 100644 (file)
index a0d5e86..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-<script lang="ts">
-       import { ModelModality } from '$lib/enums';
-       import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants/icons';
-       import { cn } from '$lib/components/ui/utils';
-
-       type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
-
-       interface Props {
-               modalities: ModelModality[];
-               class?: string;
-       }
-
-       let { modalities, class: className = '' }: Props = $props();
-
-       // Filter to only modalities that have icons (VISION, AUDIO)
-       const displayableModalities = $derived(
-               modalities.filter(
-                       (m): m is DisplayableModality => m === ModelModality.VISION || m === ModelModality.AUDIO
-               )
-       );
-</script>
-
-{#each displayableModalities as modality, index (index)}
-       {@const IconComponent = MODALITY_ICONS[modality]}
-       {@const label = MODALITY_LABELS[modality]}
-
-       <span
-               class={cn(
-                       'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
-                       className
-               )}
-       >
-               {#if IconComponent}
-                       <IconComponent class="h-3 w-3" />
-               {/if}
-
-               {label}
-       </span>
-{/each}
diff --git a/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte b/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte
deleted file mode 100644 (file)
index 702519f..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-<script lang="ts">
-       import { Dialog as DialogPrimitive } from 'bits-ui';
-       import XIcon from '@lucide/svelte/icons/x';
-
-       interface Props {
-               open: boolean;
-               code: string;
-               language: string;
-               onOpenChange?: (open: boolean) => void;
-       }
-
-       let { open = $bindable(), code, language, onOpenChange }: Props = $props();
-
-       let iframeRef = $state<HTMLIFrameElement | null>(null);
-
-       $effect(() => {
-               if (!iframeRef) return;
-
-               if (open) {
-                       iframeRef.srcdoc = code;
-               } else {
-                       iframeRef.srcdoc = '';
-               }
-       });
-
-       function handleOpenChange(nextOpen: boolean) {
-               open = nextOpen;
-               onOpenChange?.(nextOpen);
-       }
-</script>
-
-<DialogPrimitive.Root {open} onOpenChange={handleOpenChange}>
-       <DialogPrimitive.Portal>
-               <DialogPrimitive.Overlay class="code-preview-overlay" />
-
-               <DialogPrimitive.Content class="code-preview-content">
-                       <iframe
-                               bind:this={iframeRef}
-                               title="Preview {language}"
-                               sandbox="allow-scripts"
-                               class="code-preview-iframe"
-                       ></iframe>
-
-                       <DialogPrimitive.Close
-                               class="code-preview-close absolute top-4 right-4 border-none bg-transparent text-white opacity-70 mix-blend-difference transition-opacity hover:opacity-100 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-8"
-                               aria-label="Close preview"
-                       >
-                               <XIcon />
-                               <span class="sr-only">Close preview</span>
-                       </DialogPrimitive.Close>
-               </DialogPrimitive.Content>
-       </DialogPrimitive.Portal>
-</DialogPrimitive.Root>
-
-<style lang="postcss">
-       :global(.code-preview-overlay) {
-               position: fixed;
-               inset: 0;
-               background-color: transparent;
-               z-index: 100000;
-       }
-
-       :global(.code-preview-content) {
-               position: fixed;
-               inset: 0;
-               top: 0 !important;
-               left: 0 !important;
-               width: 100dvw;
-               height: 100dvh;
-               margin: 0;
-               padding: 0;
-               border: none;
-               border-radius: 0;
-               background-color: transparent;
-               box-shadow: none;
-               display: block;
-               overflow: hidden;
-               transform: none !important;
-               z-index: 100001;
-       }
-
-       :global(.code-preview-iframe) {
-               display: block;
-               width: 100dvw;
-               height: 100dvh;
-               border: 0;
-       }
-
-       :global(.code-preview-close) {
-               position: absolute;
-               z-index: 100002;
-       }
-</style>
diff --git a/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte b/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte
deleted file mode 100644 (file)
index bf6cd4f..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<script lang="ts">
-       import { Copy } from '@lucide/svelte';
-       import { copyToClipboard } from '$lib/utils';
-
-       interface Props {
-               ariaLabel?: string;
-               canCopy?: boolean;
-               text: string;
-       }
-
-       let { ariaLabel = 'Copy to clipboard', canCopy = true, text }: Props = $props();
-</script>
-
-<Copy
-       class="h-3 w-3 flex-shrink-0 cursor-{canCopy ? 'pointer' : 'not-allowed'}"
-       aria-label={ariaLabel}
-       onclick={() => canCopy && copyToClipboard(text)}
-/>
diff --git a/tools/server/webui/src/lib/components/app/misc/DropdownMenuSearchable.svelte b/tools/server/webui/src/lib/components/app/misc/DropdownMenuSearchable.svelte
deleted file mode 100644 (file)
index 21ba04c..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-<script lang="ts">
-       import type { Snippet } from 'svelte';
-       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
-       import { cn } from '$lib/components/ui/utils';
-       import { SearchInput } from '$lib/components/app';
-
-       interface Props {
-               open?: boolean;
-               onOpenChange?: (open: boolean) => void;
-               placeholder?: string;
-               searchValue?: string;
-               onSearchChange?: (value: string) => void;
-               onSearchKeyDown?: (event: KeyboardEvent) => void;
-               align?: 'start' | 'center' | 'end';
-               contentClass?: string;
-               emptyMessage?: string;
-               isEmpty?: boolean;
-               disabled?: boolean;
-               trigger: Snippet;
-               children: Snippet;
-               footer?: Snippet;
-       }
-
-       let {
-               open = $bindable(false),
-               onOpenChange,
-               placeholder = 'Search...',
-               searchValue = $bindable(''),
-               onSearchChange,
-               onSearchKeyDown,
-               align = 'start',
-               contentClass = 'w-72',
-               emptyMessage = 'No items found',
-               isEmpty = false,
-               disabled = false,
-               trigger,
-               children,
-               footer
-       }: Props = $props();
-
-       function handleOpenChange(newOpen: boolean) {
-               open = newOpen;
-
-               if (!newOpen) {
-                       searchValue = '';
-                       onSearchChange?.('');
-               }
-
-               onOpenChange?.(newOpen);
-       }
-</script>
-
-<DropdownMenu.Root bind:open onOpenChange={handleOpenChange}>
-       <DropdownMenu.Trigger
-               {disabled}
-               onclick={(e) => {
-                       e.preventDefault();
-                       e.stopPropagation();
-               }}
-       >
-               {@render trigger()}
-       </DropdownMenu.Trigger>
-
-       <DropdownMenu.Content {align} class={cn(contentClass, 'pt-0')}>
-               <div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
-                       <SearchInput
-                               {placeholder}
-                               bind:value={searchValue}
-                               onInput={onSearchChange}
-                               onKeyDown={onSearchKeyDown}
-                       />
-               </div>
-
-               <div class={cn('overflow-y-auto')}>
-                       {@render children()}
-
-                       {#if isEmpty}
-                               <div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
-                       {/if}
-               </div>
-
-               {#if footer}
-                       <DropdownMenu.Separator />
-
-                       {@render footer()}
-               {/if}
-       </DropdownMenu.Content>
-</DropdownMenu.Root>
diff --git a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
deleted file mode 100644 (file)
index 0084499..0000000
+++ /dev/null
@@ -1,872 +0,0 @@
-<script lang="ts">
-       import { remark } from 'remark';
-       import remarkBreaks from 'remark-breaks';
-       import remarkGfm from 'remark-gfm';
-       import remarkMath from 'remark-math';
-       import rehypeHighlight from 'rehype-highlight';
-       import remarkRehype from 'remark-rehype';
-       import rehypeKatex from 'rehype-katex';
-       import rehypeStringify from 'rehype-stringify';
-       import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
-       import type { Root as MdastRoot } from 'mdast';
-       import { browser } from '$app/environment';
-       import { onDestroy, tick } from 'svelte';
-       import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
-       import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
-       import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
-       import { remarkLiteralHtml } from '$lib/markdown/literal-html';
-       import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
-       import '$styles/katex-custom.scss';
-       import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
-       import githubLightCss from 'highlight.js/styles/github.css?inline';
-       import { mode } from 'mode-watcher';
-       import CodePreviewDialog from './CodePreviewDialog.svelte';
-
-       interface Props {
-               content: string;
-               class?: string;
-       }
-
-       interface MarkdownBlock {
-               id: string;
-               html: string;
-       }
-
-       let { content, class: className = '' }: Props = $props();
-
-       let containerRef = $state<HTMLDivElement>();
-       let renderedBlocks = $state<MarkdownBlock[]>([]);
-       let unstableBlockHtml = $state('');
-       let previewDialogOpen = $state(false);
-       let previewCode = $state('');
-       let previewLanguage = $state('text');
-
-       let pendingMarkdown: string | null = null;
-       let isProcessing = false;
-
-       const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
-
-       let processor = $derived(() => {
-               return remark()
-                       .use(remarkGfm) // GitHub Flavored Markdown
-                       .use(remarkMath) // Parse $inline$ and $$block$$ math
-                       .use(remarkBreaks) // Convert line breaks to <br>
-                       .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
-                       .use(remarkRehype) // Convert Markdown AST to rehype
-                       .use(rehypeKatex) // Render math using KaTeX
-                       .use(rehypeHighlight) // Add syntax highlighting
-                       .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
-                       .use(rehypeEnhanceLinks) // Add target="_blank" to links
-                       .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
-                       .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
-       });
-
-       /**
-        * Removes click event listeners from copy and preview buttons.
-        * Called on component destroy.
-        */
-       function cleanupEventListeners() {
-               if (!containerRef) return;
-
-               const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
-               const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
-
-               for (const button of copyButtons) {
-                       button.removeEventListener('click', handleCopyClick);
-               }
-
-               for (const button of previewButtons) {
-                       button.removeEventListener('click', handlePreviewClick);
-               }
-       }
-
-       /**
-        * Removes this component's highlight.js theme style from the document head.
-        * Called on component destroy to clean up injected styles.
-        */
-       function cleanupHighlightTheme() {
-               if (!browser) return;
-
-               const existingTheme = document.getElementById(themeStyleId);
-               existingTheme?.remove();
-       }
-
-       /**
-        * Loads the appropriate highlight.js theme based on dark/light mode.
-        * Injects a scoped style element into the document head.
-        * @param isDark - Whether to load the dark theme (true) or light theme (false)
-        */
-       function loadHighlightTheme(isDark: boolean) {
-               if (!browser) return;
-
-               const existingTheme = document.getElementById(themeStyleId);
-               existingTheme?.remove();
-
-               const style = document.createElement('style');
-               style.id = themeStyleId;
-               style.textContent = isDark ? githubDarkCss : githubLightCss;
-
-               document.head.appendChild(style);
-       }
-
-       /**
-        * Extracts code information from a button click target within a code block.
-        * @param target - The clicked button element
-        * @returns Object with rawCode and language, or null if extraction fails
-        */
-       function getCodeInfoFromTarget(target: HTMLElement) {
-               const wrapper = target.closest('.code-block-wrapper');
-
-               if (!wrapper) {
-                       console.error('No wrapper found');
-                       return null;
-               }
-
-               const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
-
-               if (!codeElement) {
-                       console.error('No code element found in wrapper');
-                       return null;
-               }
-
-               const rawCode = codeElement.textContent ?? '';
-
-               const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
-               const language = languageLabel?.textContent?.trim() || 'text';
-
-               return { rawCode, language };
-       }
-
-       /**
-        * Generates a unique identifier for a HAST node based on its position.
-        * Used for stable block identification during incremental rendering.
-        * @param node - The HAST root content node
-        * @param indexFallback - Fallback index if position is unavailable
-        * @returns Unique string identifier for the node
-        */
-       function getHastNodeId(node: HastRootContent, indexFallback: number): string {
-               const position = node.position;
-
-               if (position?.start?.offset != null && position?.end?.offset != null) {
-                       return `hast-${position.start.offset}-${position.end.offset}`;
-               }
-
-               return `${node.type}-${indexFallback}`;
-       }
-
-       /**
-        * Handles click events on copy buttons within code blocks.
-        * Copies the raw code content to the clipboard.
-        * @param event - The click event from the copy button
-        */
-       async function handleCopyClick(event: Event) {
-               event.preventDefault();
-               event.stopPropagation();
-
-               const target = event.currentTarget as HTMLButtonElement | null;
-
-               if (!target) {
-                       return;
-               }
-
-               const info = getCodeInfoFromTarget(target);
-
-               if (!info) {
-                       return;
-               }
-
-               try {
-                       await copyCodeToClipboard(info.rawCode);
-               } catch (error) {
-                       console.error('Failed to copy code:', error);
-               }
-       }
-
-       /**
-        * Handles preview dialog open state changes.
-        * Clears preview content when dialog is closed.
-        * @param open - Whether the dialog is being opened or closed
-        */
-       function handlePreviewDialogOpenChange(open: boolean) {
-               previewDialogOpen = open;
-
-               if (!open) {
-                       previewCode = '';
-                       previewLanguage = 'text';
-               }
-       }
-
-       /**
-        * Handles click events on preview buttons within HTML code blocks.
-        * Opens a preview dialog with the rendered HTML content.
-        * @param event - The click event from the preview button
-        */
-       function handlePreviewClick(event: Event) {
-               event.preventDefault();
-               event.stopPropagation();
-
-               const target = event.currentTarget as HTMLButtonElement | null;
-
-               if (!target) {
-                       return;
-               }
-
-               const info = getCodeInfoFromTarget(target);
-
-               if (!info) {
-                       return;
-               }
-
-               previewCode = info.rawCode;
-               previewLanguage = info.language;
-               previewDialogOpen = true;
-       }
-
-       /**
-        * Processes markdown content into stable and unstable HTML blocks.
-        * Uses incremental rendering: stable blocks are cached, unstable block is re-rendered.
-        * @param markdown - The raw markdown string to process
-        */
-       async function processMarkdown(markdown: string) {
-               if (!markdown) {
-                       renderedBlocks = [];
-                       unstableBlockHtml = '';
-                       return;
-               }
-
-               const normalized = preprocessLaTeX(markdown);
-               const processorInstance = processor();
-               const ast = processorInstance.parse(normalized) as MdastRoot;
-               const processedRoot = (await processorInstance.run(ast)) as HastRoot;
-               const processedChildren = processedRoot.children ?? [];
-               const stableCount = Math.max(processedChildren.length - 1, 0);
-               const nextBlocks: MarkdownBlock[] = [];
-
-               for (let index = 0; index < stableCount; index++) {
-                       const hastChild = processedChildren[index];
-                       const id = getHastNodeId(hastChild, index);
-                       const existing = renderedBlocks[index];
-
-                       if (existing && existing.id === id) {
-                               nextBlocks.push(existing);
-                               continue;
-                       }
-
-                       const html = stringifyProcessedNode(
-                               processorInstance,
-                               processedRoot,
-                               processedChildren[index]
-                       );
-
-                       nextBlocks.push({ id, html });
-               }
-
-               let unstableHtml = '';
-
-               if (processedChildren.length > stableCount) {
-                       const unstableChild = processedChildren[stableCount];
-                       unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild);
-               }
-
-               renderedBlocks = nextBlocks;
-               await tick(); // Force DOM sync before updating unstable HTML block
-               unstableBlockHtml = unstableHtml;
-       }
-
-       /**
-        * Attaches click event listeners to copy and preview buttons in code blocks.
-        * Uses data-listener-bound attribute to prevent duplicate bindings.
-        */
-       function setupCodeBlockActions() {
-               if (!containerRef) return;
-
-               const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
-
-               for (const wrapper of wrappers) {
-                       const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
-                       const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
-
-                       if (copyButton && copyButton.dataset.listenerBound !== 'true') {
-                               copyButton.dataset.listenerBound = 'true';
-                               copyButton.addEventListener('click', handleCopyClick);
-                       }
-
-                       if (previewButton && previewButton.dataset.listenerBound !== 'true') {
-                               previewButton.dataset.listenerBound = 'true';
-                               previewButton.addEventListener('click', handlePreviewClick);
-                       }
-               }
-       }
-
-       /**
-        * Converts a single HAST node to an enhanced HTML string.
-        * Applies link and code block enhancements to the output.
-        * @param processorInstance - The remark/rehype processor instance
-        * @param processedRoot - The full processed HAST root (for context)
-        * @param child - The specific HAST child node to stringify
-        * @returns Enhanced HTML string representation of the node
-        */
-       function stringifyProcessedNode(
-               processorInstance: ReturnType<typeof processor>,
-               processedRoot: HastRoot,
-               child: unknown
-       ) {
-               const root: HastRoot = {
-                       ...(processedRoot as HastRoot),
-                       children: [child as never]
-               };
-
-               return processorInstance.stringify(root);
-       }
-
-       /**
-        * Queues markdown for processing with coalescing support.
-        * Only processes the latest markdown when multiple updates arrive quickly.
-        * @param markdown - The markdown content to render
-        */
-       async function updateRenderedBlocks(markdown: string) {
-               pendingMarkdown = markdown;
-
-               if (isProcessing) {
-                       return;
-               }
-
-               isProcessing = true;
-
-               try {
-                       while (pendingMarkdown !== null) {
-                               const nextMarkdown = pendingMarkdown;
-                               pendingMarkdown = null;
-
-                               await processMarkdown(nextMarkdown);
-                       }
-               } catch (error) {
-                       console.error('Failed to process markdown:', error);
-                       renderedBlocks = [];
-                       unstableBlockHtml = markdown.replace(/\n/g, '<br>');
-               } finally {
-                       isProcessing = false;
-               }
-       }
-
-       $effect(() => {
-               const currentMode = mode.current;
-               const isDark = currentMode === 'dark';
-
-               loadHighlightTheme(isDark);
-       });
-
-       $effect(() => {
-               updateRenderedBlocks(content);
-       });
-
-       $effect(() => {
-               const hasRenderedBlocks = renderedBlocks.length > 0;
-               const hasUnstableBlock = Boolean(unstableBlockHtml);
-
-               if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
-                       setupCodeBlockActions();
-               }
-       });
-
-       onDestroy(() => {
-               cleanupEventListeners();
-               cleanupHighlightTheme();
-       });
-</script>
-
-<div bind:this={containerRef} class={className}>
-       {#each renderedBlocks as block (block.id)}
-               <div class="markdown-block" data-block-id={block.id}>
-                       <!-- eslint-disable-next-line no-at-html-tags -->
-                       {@html block.html}
-               </div>
-       {/each}
-
-       {#if unstableBlockHtml}
-               <div class="markdown-block markdown-block--unstable" data-block-id="unstable">
-                       <!-- eslint-disable-next-line no-at-html-tags -->
-                       {@html unstableBlockHtml}
-               </div>
-       {/if}
-</div>
-
-<CodePreviewDialog
-       open={previewDialogOpen}
-       code={previewCode}
-       language={previewLanguage}
-       onOpenChange={handlePreviewDialogOpenChange}
-/>
-
-<style>
-       .markdown-block,
-       .markdown-block--unstable {
-               display: contents;
-       }
-
-       /* Base typography styles */
-       div :global(p:not(:last-child)) {
-               margin-bottom: 1rem;
-               line-height: 1.75;
-       }
-
-       div :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
-               margin-top: 0;
-       }
-
-       /* Headers with consistent spacing */
-       div :global(h1) {
-               font-size: 1.875rem;
-               font-weight: 700;
-               line-height: 1.2;
-               margin: 1.5rem 0 0.75rem 0;
-       }
-
-       div :global(h2) {
-               font-size: 1.5rem;
-               font-weight: 600;
-               line-height: 1.3;
-               margin: 1.25rem 0 0.5rem 0;
-       }
-
-       div :global(h3) {
-               font-size: 1.25rem;
-               font-weight: 600;
-               margin: 1.5rem 0 0.5rem 0;
-               line-height: 1.4;
-       }
-
-       div :global(h4) {
-               font-size: 1.125rem;
-               font-weight: 600;
-               margin: 0.75rem 0 0.25rem 0;
-       }
-
-       div :global(h5) {
-               font-size: 1rem;
-               font-weight: 600;
-               margin: 0.5rem 0 0.25rem 0;
-       }
-
-       div :global(h6) {
-               font-size: 0.875rem;
-               font-weight: 600;
-               margin: 0.5rem 0 0.25rem 0;
-       }
-
-       /* Text formatting */
-       div :global(strong) {
-               font-weight: 600;
-       }
-
-       div :global(em) {
-               font-style: italic;
-       }
-
-       div :global(del) {
-               text-decoration: line-through;
-               opacity: 0.7;
-       }
-
-       /* Inline code */
-       div :global(code:not(pre code)) {
-               background: var(--muted);
-               color: var(--muted-foreground);
-               padding: 0.125rem 0.375rem;
-               border-radius: 0.375rem;
-               font-size: 0.875rem;
-               font-family:
-                       ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
-                       'Liberation Mono', Menlo, monospace;
-       }
-
-       /* Links */
-       div :global(a) {
-               color: var(--primary);
-               text-decoration: underline;
-               text-underline-offset: 2px;
-               transition: color 0.2s ease;
-               overflow-wrap: anywhere;
-               word-break: break-all;
-       }
-
-       div :global(a:hover) {
-               color: var(--primary);
-       }
-
-       /* Lists */
-       div :global(ul) {
-               list-style-type: disc;
-               margin-left: 1.5rem;
-               margin-bottom: 1rem;
-       }
-
-       div :global(ol) {
-               list-style-type: decimal;
-               margin-left: 1.5rem;
-               margin-bottom: 1rem;
-       }
-
-       div :global(li) {
-               margin-bottom: 0.25rem;
-               padding-left: 0.5rem;
-       }
-
-       div :global(li::marker) {
-               color: var(--muted-foreground);
-       }
-
-       /* Nested lists */
-       div :global(ul ul) {
-               list-style-type: circle;
-               margin-top: 0.25rem;
-               margin-bottom: 0.25rem;
-       }
-
-       div :global(ol ol) {
-               list-style-type: lower-alpha;
-               margin-top: 0.25rem;
-               margin-bottom: 0.25rem;
-       }
-
-       /* Task lists */
-       div :global(.task-list-item) {
-               list-style: none;
-               margin-left: 0;
-               padding-left: 0;
-       }
-
-       div :global(.task-list-item-checkbox) {
-               margin-right: 0.5rem;
-               margin-top: 0.125rem;
-       }
-
-       /* Blockquotes */
-       div :global(blockquote) {
-               border-left: 4px solid var(--border);
-               padding: 0.5rem 1rem;
-               margin: 1.5rem 0;
-               font-style: italic;
-               color: var(--muted-foreground);
-               background: var(--muted);
-               border-radius: 0 0.375rem 0.375rem 0;
-       }
-
-       /* Tables */
-       div :global(table) {
-               width: 100%;
-               margin: 1.5rem 0;
-               border-collapse: collapse;
-               border: 1px solid var(--border);
-               border-radius: 0.375rem;
-               overflow: hidden;
-       }
-
-       div :global(th) {
-               background: hsl(var(--muted) / 0.3);
-               border: 1px solid var(--border);
-               padding: 0.5rem 0.75rem;
-               text-align: left;
-               font-weight: 600;
-       }
-
-       div :global(td) {
-               border: 1px solid var(--border);
-               padding: 0.5rem 0.75rem;
-       }
-
-       div :global(tr:nth-child(even)) {
-               background: hsl(var(--muted) / 0.1);
-       }
-
-       /* User message markdown should keep table borders visible on light primary backgrounds */
-       div.markdown-user-content :global(table),
-       div.markdown-user-content :global(th),
-       div.markdown-user-content :global(td),
-       div.markdown-user-content :global(.table-wrapper) {
-               border-color: currentColor;
-       }
-
-       /* Horizontal rules */
-       div :global(hr) {
-               border: none;
-               border-top: 1px solid var(--border);
-               margin: 1.5rem 0;
-       }
-
-       /* Images */
-       div :global(img) {
-               border-radius: 0.5rem;
-               box-shadow:
-                       0 1px 3px 0 rgb(0 0 0 / 0.1),
-                       0 1px 2px -1px rgb(0 0 0 / 0.1);
-               margin: 1.5rem 0;
-               max-width: 100%;
-               height: auto;
-       }
-
-       /* Code blocks */
-
-       div :global(.code-block-wrapper) {
-               margin: 1.5rem 0;
-               border-radius: 0.75rem;
-               overflow: hidden;
-               border: 1px solid var(--border);
-               background: var(--code-background);
-       }
-
-       div :global(.code-block-header) {
-               display: flex;
-               justify-content: space-between;
-               align-items: center;
-               padding: 0.5rem 1rem;
-               background: hsl(var(--muted) / 0.5);
-               border-bottom: 1px solid var(--border);
-               font-size: 0.875rem;
-       }
-
-       div :global(.code-language) {
-               color: var(--code-foreground);
-               font-weight: 500;
-               font-family:
-                       ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
-                       'Liberation Mono', Menlo, monospace;
-               text-transform: uppercase;
-               font-size: 0.75rem;
-               letter-spacing: 0.05em;
-       }
-
-       div :global(.code-block-actions) {
-               display: flex;
-               align-items: center;
-               gap: 0.5rem;
-       }
-
-       div :global(.copy-code-btn),
-       div :global(.preview-code-btn) {
-               display: flex;
-               align-items: center;
-               justify-content: center;
-               padding: 0;
-               background: transparent;
-               color: var(--code-foreground);
-               cursor: pointer;
-               transition: all 0.2s ease;
-       }
-
-       div :global(.copy-code-btn:hover),
-       div :global(.preview-code-btn:hover) {
-               transform: scale(1.05);
-       }
-
-       div :global(.copy-code-btn:active),
-       div :global(.preview-code-btn:active) {
-               transform: scale(0.95);
-       }
-
-       div :global(.code-block-wrapper pre) {
-               background: transparent;
-               padding: 1rem;
-               margin: 0;
-               overflow-x: auto;
-               border-radius: 0;
-               border: none;
-               font-size: 0.875rem;
-               line-height: 1.5;
-       }
-
-       div :global(pre) {
-               background: var(--muted);
-               margin: 1.5rem 0;
-               overflow-x: auto;
-               border-radius: 1rem;
-               border: none;
-       }
-
-       div :global(code) {
-               background: transparent;
-               color: var(--code-foreground);
-       }
-
-       /* Mentions and hashtags */
-       div :global(.mention) {
-               color: hsl(var(--primary));
-               font-weight: 500;
-               text-decoration: none;
-       }
-
-       div :global(.mention:hover) {
-               text-decoration: underline;
-       }
-
-       div :global(.hashtag) {
-               color: hsl(var(--primary));
-               font-weight: 500;
-               text-decoration: none;
-       }
-
-       div :global(.hashtag:hover) {
-               text-decoration: underline;
-       }
-
-       /* Advanced table enhancements */
-       div :global(table) {
-               transition: all 0.2s ease;
-       }
-
-       div :global(table:hover) {
-               box-shadow:
-                       0 4px 6px -1px rgb(0 0 0 / 0.1),
-                       0 2px 4px -2px rgb(0 0 0 / 0.1);
-       }
-
-       div :global(th:hover),
-       div :global(td:hover) {
-               background: var(--muted);
-       }
-
-       /* Disable hover effects when rendering user messages */
-       .markdown-user-content :global(a),
-       .markdown-user-content :global(a:hover) {
-               color: var(--primary-foreground);
-       }
-
-       .markdown-user-content :global(table:hover) {
-               box-shadow: none;
-       }
-
-       .markdown-user-content :global(th:hover),
-       .markdown-user-content :global(td:hover) {
-               background: inherit;
-       }
-
-       /* Enhanced blockquotes */
-       div :global(blockquote) {
-               transition: all 0.2s ease;
-               position: relative;
-       }
-
-       div :global(blockquote:hover) {
-               border-left-width: 6px;
-               background: var(--muted);
-               transform: translateX(2px);
-       }
-
-       div :global(blockquote::before) {
-               content: '"';
-               position: absolute;
-               top: -0.5rem;
-               left: 0.5rem;
-               font-size: 3rem;
-               color: var(--muted-foreground);
-               font-family: serif;
-               line-height: 1;
-       }
-
-       /* Enhanced images */
-       div :global(img) {
-               transition: all 0.3s ease;
-               cursor: pointer;
-       }
-
-       div :global(img:hover) {
-               transform: scale(1.02);
-               box-shadow:
-                       0 10px 15px -3px rgb(0 0 0 / 0.1),
-                       0 4px 6px -4px rgb(0 0 0 / 0.1);
-       }
-
-       /* Image zoom overlay */
-       div :global(.image-zoom-overlay) {
-               position: fixed;
-               top: 0;
-               left: 0;
-               right: 0;
-               bottom: 0;
-               background: rgba(0, 0, 0, 0.8);
-               display: flex;
-               align-items: center;
-               justify-content: center;
-               z-index: 1000;
-               cursor: pointer;
-       }
-
-       div :global(.image-zoom-overlay img) {
-               max-width: 90vw;
-               max-height: 90vh;
-               border-radius: 0.5rem;
-               box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
-       }
-
-       /* Enhanced horizontal rules */
-       div :global(hr) {
-               border: none;
-               height: 2px;
-               background: linear-gradient(to right, transparent, var(--border), transparent);
-               margin: 2rem 0;
-               position: relative;
-       }
-
-       div :global(hr::after) {
-               content: '';
-               position: absolute;
-               top: 50%;
-               left: 50%;
-               transform: translate(-50%, -50%);
-               width: 1rem;
-               height: 1rem;
-               background: var(--border);
-               border-radius: 50%;
-       }
-
-       /* Scrollable tables */
-       div :global(.table-wrapper) {
-               overflow-x: auto;
-               margin: 1.5rem 0;
-               border-radius: 0.5rem;
-               border: 1px solid var(--border);
-       }
-
-       div :global(.table-wrapper table) {
-               margin: 0;
-               border: none;
-       }
-
-       /* Responsive adjustments */
-       @media (max-width: 640px) {
-               div :global(h1) {
-                       font-size: 1.5rem;
-               }
-
-               div :global(h2) {
-                       font-size: 1.25rem;
-               }
-
-               div :global(h3) {
-                       font-size: 1.125rem;
-               }
-
-               div :global(table) {
-                       font-size: 0.875rem;
-               }
-
-               div :global(th),
-               div :global(td) {
-                       padding: 0.375rem 0.5rem;
-               }
-
-               div :global(.table-wrapper) {
-                       margin: 0.5rem -1rem;
-                       border-radius: 0;
-                       border-left: none;
-                       border-right: none;
-               }
-       }
-
-       /* Dark mode adjustments */
-       @media (prefers-color-scheme: dark) {
-               div :global(blockquote:hover) {
-                       background: var(--muted);
-               }
-       }
-</style>
diff --git a/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte b/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte
deleted file mode 100644 (file)
index 1736855..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<script lang="ts">
-       import { X } from '@lucide/svelte';
-       import { Button } from '$lib/components/ui/button';
-
-       interface Props {
-               id: string;
-               onRemove?: (id: string) => void;
-               class?: string;
-       }
-
-       let { id, onRemove, class: className = '' }: Props = $props();
-</script>
-
-<Button
-       type="button"
-       variant="ghost"
-       size="sm"
-       class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
-       onclick={(e) => {
-               e.stopPropagation();
-               onRemove?.(id);
-       }}
-       aria-label="Remove file"
->
-       <X class="h-3 w-3" />
-</Button>
diff --git a/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte b/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte
deleted file mode 100644 (file)
index 15cd6ab..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-<script lang="ts">
-       import { Input } from '$lib/components/ui/input';
-       import { Search, X } from '@lucide/svelte';
-
-       interface Props {
-               value?: string;
-               placeholder?: string;
-               onInput?: (value: string) => void;
-               onClose?: () => void;
-               onKeyDown?: (event: KeyboardEvent) => void;
-               class?: string;
-               id?: string;
-               ref?: HTMLInputElement | null;
-       }
-
-       let {
-               value = $bindable(''),
-               placeholder = 'Search...',
-               onInput,
-               onClose,
-               onKeyDown,
-               class: className,
-               id,
-               ref = $bindable(null)
-       }: Props = $props();
-
-       let showClearButton = $derived(!!value || !!onClose);
-
-       function handleInput(event: Event) {
-               const target = event.target as HTMLInputElement;
-
-               value = target.value;
-               onInput?.(target.value);
-       }
-
-       function handleClear() {
-               if (value) {
-                       value = '';
-                       onInput?.('');
-                       ref?.focus();
-               } else {
-                       onClose?.();
-               }
-       }
-</script>
-
-<div class="relative {className}">
-       <Search
-               class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
-       />
-
-       <Input
-               {id}
-               bind:value
-               bind:ref
-               class="pl-9 {showClearButton ? 'pr-9' : ''}"
-               oninput={handleInput}
-               onkeydown={onKeyDown}
-               {placeholder}
-               type="search"
-       />
-
-       {#if showClearButton}
-               <button
-                       type="button"
-                       class="absolute top-1/2 right-3 -translate-y-1/2 transform text-muted-foreground transition-colors hover:text-foreground"
-                       onclick={handleClear}
-                       aria-label={value ? 'Clear search' : 'Close'}
-               >
-                       <X class="h-4 w-4" />
-               </button>
-       {/if}
-</div>
diff --git a/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte b/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte
deleted file mode 100644 (file)
index bc42f9d..0000000
+++ /dev/null
@@ -1,97 +0,0 @@
-<script lang="ts">
-       import hljs from 'highlight.js';
-       import { browser } from '$app/environment';
-       import { mode } from 'mode-watcher';
-
-       import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
-       import githubLightCss from 'highlight.js/styles/github.css?inline';
-
-       interface Props {
-               code: string;
-               language?: string;
-               class?: string;
-               maxHeight?: string;
-               maxWidth?: string;
-       }
-
-       let {
-               code,
-               language = 'text',
-               class: className = '',
-               maxHeight = '60vh',
-               maxWidth = ''
-       }: Props = $props();
-
-       let highlightedHtml = $state('');
-
-       function loadHighlightTheme(isDark: boolean) {
-               if (!browser) return;
-
-               const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]');
-               existingThemes.forEach((style) => style.remove());
-
-               const style = document.createElement('style');
-               style.setAttribute('data-highlight-theme-preview', 'true');
-               style.textContent = isDark ? githubDarkCss : githubLightCss;
-
-               document.head.appendChild(style);
-       }
-
-       $effect(() => {
-               const currentMode = mode.current;
-               const isDark = currentMode === 'dark';
-
-               loadHighlightTheme(isDark);
-       });
-
-       $effect(() => {
-               if (!code) {
-                       highlightedHtml = '';
-                       return;
-               }
-
-               try {
-                       // Check if the language is supported
-                       const lang = language.toLowerCase();
-                       const isSupported = hljs.getLanguage(lang);
-
-                       if (isSupported) {
-                               const result = hljs.highlight(code, { language: lang });
-                               highlightedHtml = result.value;
-                       } else {
-                               // Try auto-detection or fallback to plain text
-                               const result = hljs.highlightAuto(code);
-                               highlightedHtml = result.value;
-                       }
-               } catch {
-                       // Fallback to escaped plain text
-                       highlightedHtml = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-               }
-       });
-</script>
-
-<div
-       class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
-       style="max-height: {maxHeight}; max-width: {maxWidth};"
->
-       <!-- Needs to be formatted as single line for proper rendering -->
-       <pre class="m-0 overflow-x-auto p-4"><code class="hljs text-sm leading-relaxed"
-                       >{@html highlightedHtml}</code
-               ></pre>
-</div>
-
-<style>
-       .code-preview-wrapper {
-               font-family:
-                       ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
-                       'Liberation Mono', Menlo, monospace;
-       }
-
-       .code-preview-wrapper pre {
-               background: transparent;
-       }
-
-       .code-preview-wrapper code {
-               background: transparent;
-       }
-</style>
index bea1bf6e3f9d557a28a7f8358457d7204dba18f6..f98ba7d78d7569fe8a4a3ed7a95ebe9ea9e2d1e2 100644 (file)
@@ -1,6 +1,6 @@
 <script lang="ts">
        import { Package } from '@lucide/svelte';
-       import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app';
+       import { BadgeInfo, ActionIconCopyToClipboard } from '$lib/components/app';
        import { modelsStore } from '$lib/stores/models.svelte';
        import { serverStore } from '$lib/stores/server.svelte';
        import * as Tooltip from '$lib/components/ui/tooltip';
@@ -34,7 +34,7 @@
                {model}
 
                {#if showCopyIcon}
-                       <CopyToClipboardIcon text={model || ''} ariaLabel="Copy model name" />
+                       <ActionIconCopyToClipboard text={model || ''} ariaLabel="Copy model name" />
                {/if}
        </BadgeInfo>
 {/snippet}
index efc9cd4e2f8085bd65ce86707f509fdfebd369e7..ec27293078e8592a23ed3f8a451758565ed0a758 100644 (file)
@@ -1,8 +1,8 @@
 <script lang="ts">
-       import { onMount, tick } from 'svelte';
-       import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
+       import { onMount } from 'svelte';
+       import { ChevronDown, Loader2, Package, Power } from '@lucide/svelte';
+       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
        import * as Tooltip from '$lib/components/ui/tooltip';
-       import * as Popover from '$lib/components/ui/popover';
        import { cn } from '$lib/components/ui/utils';
        import {
                modelsStore,
                modelsUpdating,
                selectedModelId,
                routerModels,
-               propsCacheVersion,
                singleModelName
        } from '$lib/stores/models.svelte';
-       import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
-       import { ServerModelStatus } from '$lib/enums';
+       import { KeyboardKey, ServerModelStatus } from '$lib/enums';
        import { isRouterMode } from '$lib/stores/server.svelte';
-       import { DialogModelInformation, SearchInput } from '$lib/components/app';
+       import {
+               DialogModelInformation,
+               DropdownMenuSearchable,
+               TruncatedText
+       } from '$lib/components/app';
        import type { ModelOption } from '$lib/types/models';
 
        interface Props {
                forceForegroundText?: boolean;
                /** When true, user's global selection takes priority over currentModel (for form selector) */
                useGlobalSelection?: boolean;
-               /**
-                * When provided, only consider modalities from messages BEFORE this message.
-                * Used for regeneration - allows selecting models that don't support modalities
-                * used in later messages.
-                */
+               /** Optional compatibility prop for context-aware selectors. */
                upToMessageId?: string;
        }
 
@@ -44,7 +42,8 @@
                disabled = false,
                forceForegroundText = false,
                useGlobalSelection = false,
-               upToMessageId
+               // eslint-disable-next-line @typescript-eslint/no-unused-vars
+               upToMessageId: _upToMessageId = undefined
        }: Props = $props();
 
        let options = $derived(modelOptions());
        // Reactive router models state - needed for proper reactivity of status checks
        let currentRouterModels = $derived(routerModels());
 
-       let requiredModalities = $derived(
-               upToMessageId ? conversationsStore.getModalitiesUpToMessage(upToMessageId) : usedModalities()
-       );
-
        function getModelStatus(modelId: string): ServerModelStatus | null {
                const model = currentRouterModels.find((m) => m.id === modelId);
                return (model?.status?.value as ServerModelStatus) ?? null;
        }
 
-       /**
-        * Checks if a model supports all modalities used in the conversation.
-        * Returns true if the model can be selected, false if it should be disabled.
-        */
-       function isModelCompatible(option: ModelOption): boolean {
-               void propsCacheVersion();
-
-               const modelModalities = modelsStore.getModelModalities(option.model);
-
-               if (!modelModalities) {
-                       const status = getModelStatus(option.model);
-
-                       if (status === ServerModelStatus.LOADED) {
-                               if (requiredModalities.vision || requiredModalities.audio) return false;
-                       }
-
-                       return true;
-               }
-
-               if (requiredModalities.vision && !modelModalities.vision) return false;
-               if (requiredModalities.audio && !modelModalities.audio) return false;
-
-               return true;
-       }
-
-       /**
-        * Gets missing modalities for a model.
-        * Returns object with vision/audio booleans indicating what's missing.
-        */
-       function getMissingModalities(option: ModelOption): { vision: boolean; audio: boolean } | null {
-               void propsCacheVersion();
-
-               const modelModalities = modelsStore.getModelModalities(option.model);
-
-               if (!modelModalities) {
-                       const status = getModelStatus(option.model);
-
-                       if (status === ServerModelStatus.LOADED) {
-                               const missing = {
-                                       vision: requiredModalities.vision,
-                                       audio: requiredModalities.audio
-                               };
-
-                               if (missing.vision || missing.audio) return missing;
-                       }
-
-                       return null;
-               }
-
-               const missing = {
-                       vision: requiredModalities.vision && !modelModalities.vision,
-                       audio: requiredModalities.audio && !modelModalities.audio
-               };
-
-               if (!missing.vision && !missing.audio) return null;
-
-               return missing;
-       }
-
        let isHighlightedCurrentModelActive = $derived(
                !isRouter || !currentModel
                        ? false
        });
 
        let searchTerm = $state('');
-       let searchInputRef = $state<HTMLInputElement | null>(null);
        let highlightedIndex = $state<number>(-1);
 
        let filteredOptions: ModelOption[] = $derived(
                })()
        );
 
-       // Get indices of compatible options for keyboard navigation
-       let compatibleIndices = $derived(
-               filteredOptions
-                       .map((option, index) => (isModelCompatible(option) ? index : -1))
-                       .filter((i) => i !== -1)
-       );
-
        // Reset highlighted index when search term changes
        $effect(() => {
                void searchTerm;
                });
        });
 
-       // Handle changes to the model selector pop-down or the model dialog, depending on if the server is in
+       // Handle changes to the model selector dropdown or the model dialog, depending on if the server is in
        // router mode or not.
        function handleOpenChange(open: boolean) {
                if (loading || updating) return;
                                searchTerm = '';
                                highlightedIndex = -1;
 
-                               // Focus search input after popover opens
-                               tick().then(() => {
-                                       requestAnimationFrame(() => searchInputRef?.focus());
-                               });
-
                                modelsStore.fetchRouterModels().then(() => {
                                        modelsStore.fetchModalitiesForLoadedModels();
                                });
        function handleSearchKeyDown(event: KeyboardEvent) {
                if (event.isComposing) return;
 
-               if (event.key === 'ArrowDown') {
+               if (event.key === KeyboardKey.ARROW_DOWN) {
                        event.preventDefault();
-                       if (compatibleIndices.length === 0) return;
+                       if (filteredOptions.length === 0) return;
 
-                       const currentPos = compatibleIndices.indexOf(highlightedIndex);
-                       if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
-                               highlightedIndex = compatibleIndices[0];
+                       if (highlightedIndex === -1 || highlightedIndex === filteredOptions.length - 1) {
+                               highlightedIndex = 0;
                        } else {
-                               highlightedIndex = compatibleIndices[currentPos + 1];
+                               highlightedIndex += 1;
                        }
-               } else if (event.key === 'ArrowUp') {
+               } else if (event.key === KeyboardKey.ARROW_UP) {
                        event.preventDefault();
-                       if (compatibleIndices.length === 0) return;
+                       if (filteredOptions.length === 0) return;
 
-                       const currentPos = compatibleIndices.indexOf(highlightedIndex);
-                       if (currentPos === -1 || currentPos === 0) {
-                               highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
+                       if (highlightedIndex === -1 || highlightedIndex === 0) {
+                               highlightedIndex = filteredOptions.length - 1;
                        } else {
-                               highlightedIndex = compatibleIndices[currentPos - 1];
+                               highlightedIndex -= 1;
                        }
-               } else if (event.key === 'Enter') {
+               } else if (event.key === KeyboardKey.ENTER) {
                        event.preventDefault();
                        if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
                                const option = filteredOptions[highlightedIndex];
-                               if (isModelCompatible(option)) {
-                                       handleSelect(option.id);
-                               }
-                       } else if (compatibleIndices.length > 0) {
-                               // No selection - highlight first compatible option
-                               highlightedIndex = compatibleIndices[0];
+                               handleSelect(option.id);
+                       } else if (filteredOptions.length > 0) {
+                               // No selection - highlight first option
+                               highlightedIndex = 0;
                        }
                }
        }
                {@const selectedOption = getDisplayOption()}
 
                {#if isRouter}
-                       <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
-                               <Popover.Trigger
-                                       class={cn(
-                                               `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
-                                               !isCurrentModelInCache()
-                                                       ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
-                                                       : forceForegroundText
-                                                               ? 'text-foreground'
-                                                               : isHighlightedCurrentModelActive
-                                                                       ? 'text-foreground'
-                                                                       : 'text-muted-foreground',
-                                               isOpen ? 'text-foreground' : ''
-                                       )}
-                                       style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
+                       <DropdownMenu.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
+                               <DropdownMenu.Trigger
                                        disabled={disabled || updating}
+                                       onclick={(e) => {
+                                               e.preventDefault();
+                                               e.stopPropagation();
+                                       }}
                                >
-                                       <Package class="h-3.5 w-3.5" />
-
-                                       <span class="truncate font-medium">
-                                               {selectedOption?.model || 'Select model'}
-                                       </span>
-
-                                       {#if updating}
-                                               <Loader2 class="h-3 w-3.5 animate-spin" />
-                                       {:else}
-                                               <ChevronDown class="h-3 w-3.5" />
-                                       {/if}
-                               </Popover.Trigger>
-
-                               <Popover.Content
-                                       class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
+                                       <button
+                                               type="button"
+                                               class={cn(
+                                                       `inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
+                                                       !isCurrentModelInCache()
+                                                               ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
+                                                               : forceForegroundText
+                                                                       ? 'text-foreground'
+                                                                       : isHighlightedCurrentModelActive
+                                                                               ? 'text-foreground'
+                                                                               : 'text-muted-foreground',
+                                                       isOpen ? 'text-foreground' : ''
+                                               )}
+                                               style="max-width: min(calc(100cqw - 9rem), 20rem)"
+                                               disabled={disabled || updating}
+                                       >
+                                               <Package class="h-3.5 w-3.5" />
+
+                                               <TruncatedText
+                                                       text={selectedOption?.model || 'Select model'}
+                                                       class="min-w-0 font-medium"
+                                               />
+
+                                               {#if updating}
+                                                       <Loader2 class="h-3 w-3.5 animate-spin" />
+                                               {:else}
+                                                       <ChevronDown class="h-3 w-3.5" />
+                                               {/if}
+                                       </button>
+                               </DropdownMenu.Trigger>
+
+                               <DropdownMenu.Content
                                        align="end"
-                                       sideOffset={8}
-                                       collisionPadding={16}
+                                       class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
                                >
-                                       <div class="flex max-h-[50dvh] flex-col overflow-hidden">
-                                               <div
-                                                       class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0"
-                                               >
-                                                       <SearchInput
-                                                               id="model-search"
-                                                               placeholder="Search models..."
-                                                               bind:value={searchTerm}
-                                                               bind:ref={searchInputRef}
-                                                               onClose={() => handleOpenChange(false)}
-                                                               onKeyDown={handleSearchKeyDown}
-                                                       />
-                                               </div>
-                                               <div
-                                                       class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
-                                               >
+                                       <DropdownMenuSearchable
+                                               bind:searchValue={searchTerm}
+                                               placeholder="Search models..."
+                                               onSearchKeyDown={handleSearchKeyDown}
+                                               emptyMessage="No models found."
+                                               isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()}
+                                       >
+                                               <div class="models-list">
                                                        {#if !isCurrentModelInCache() && currentModel}
                                                                <!-- Show unavailable model as first option (disabled) -->
                                                                <button
                                                                        type="button"
-                                                                       class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
+                                                                       class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
                                                                        role="option"
                                                                        aria-selected="true"
                                                                        aria-disabled="true"
                                                                        disabled
                                                                >
-                                                                       <span class="truncate">{selectedOption?.name || currentModel}</span>
+                                                                       <span
+                                                                               class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:text-clip sm:whitespace-nowrap"
+                                                                       >
+                                                                               {selectedOption?.name || currentModel}
+                                                                       </span>
                                                                        <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
                                                                </button>
                                                                <div class="my-1 h-px bg-border"></div>
                                                                {@const isLoaded = status === ServerModelStatus.LOADED}
                                                                {@const isLoading = status === ServerModelStatus.LOADING}
                                                                {@const isSelected = currentModel === option.model || activeId === option.id}
-                                                               {@const isCompatible = isModelCompatible(option)}
                                                                {@const isHighlighted = index === highlightedIndex}
-                                                               {@const missingModalities = getMissingModalities(option)}
 
                                                                <div
                                                                        class={cn(
-                                                                               'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
-                                                                               isCompatible
-                                                                                       ? 'cursor-pointer hover:bg-muted focus:bg-muted'
-                                                                                       : 'cursor-not-allowed opacity-50',
+                                                                               'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
+                                                                               'cursor-pointer hover:bg-muted focus:bg-muted',
                                                                                isSelected || isHighlighted
                                                                                        ? 'bg-accent text-accent-foreground'
-                                                                                       : isCompatible
-                                                                                               ? 'hover:bg-accent hover:text-accent-foreground'
-                                                                                               : '',
+                                                                                       : 'hover:bg-accent hover:text-accent-foreground',
                                                                                isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
                                                                        )}
                                                                        role="option"
                                                                        aria-selected={isSelected || isHighlighted}
-                                                                       aria-disabled={!isCompatible}
-                                                                       tabindex={isCompatible ? 0 : -1}
-                                                                       onclick={() => isCompatible && handleSelect(option.id)}
+                                                                       tabindex="0"
+                                                                       onclick={() => handleSelect(option.id)}
                                                                        onmouseenter={() => (highlightedIndex = index)}
                                                                        onkeydown={(e) => {
-                                                                               if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
+                                                                               if (e.key === 'Enter' || e.key === ' ') {
                                                                                        e.preventDefault();
                                                                                        handleSelect(option.id);
                                                                                }
                                                                        }}
                                                                >
-                                                                       <span class="min-w-0 flex-1 truncate">{option.model}</span>
-
-                                                                       {#if missingModalities}
-                                                                               <span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
-                                                                                       {#if missingModalities.vision}
-                                                                                               <Tooltip.Root>
-                                                                                                       <Tooltip.Trigger>
-                                                                                                               <EyeOff class="h-3.5 w-3.5" />
-                                                                                                       </Tooltip.Trigger>
-                                                                                                       <Tooltip.Content class="z-[9999]">
-                                                                                                               <p>No vision support</p>
-                                                                                                       </Tooltip.Content>
-                                                                                               </Tooltip.Root>
-                                                                                       {/if}
-                                                                                       {#if missingModalities.audio}
-                                                                                               <Tooltip.Root>
-                                                                                                       <Tooltip.Trigger>
-                                                                                                               <MicOff class="h-3.5 w-3.5" />
-                                                                                                       </Tooltip.Trigger>
-                                                                                                       <Tooltip.Content class="z-[9999]">
-                                                                                                               <p>No audio support</p>
-                                                                                                       </Tooltip.Content>
-                                                                                               </Tooltip.Root>
-                                                                                       {/if}
-                                                                               </span>
-                                                                       {/if}
-
-                                                                       {#if isLoading}
-                                                                               <Tooltip.Root>
-                                                                                       <Tooltip.Trigger>
-                                                                                               <Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
-                                                                                       </Tooltip.Trigger>
-                                                                                       <Tooltip.Content class="z-[9999]">
-                                                                                               <p>Loading model...</p>
-                                                                                       </Tooltip.Content>
-                                                                               </Tooltip.Root>
-                                                                       {:else if isLoaded}
-                                                                               <Tooltip.Root>
-                                                                                       <Tooltip.Trigger>
-                                                                                               <button
-                                                                                                       type="button"
-                                                                                                       class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
-                                                                                                       onclick={(e) => {
-                                                                                                               e.stopPropagation();
-                                                                                                               modelsStore.unloadModel(option.model);
-                                                                                                       }}
-                                                                                               >
-                                                                                                       <span
-                                                                                                               class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
-                                                                                                       ></span>
-                                                                                                       <Power
-                                                                                                               class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
-                                                                                                       />
-                                                                                               </button>
-                                                                                       </Tooltip.Trigger>
-                                                                                       <Tooltip.Content class="z-[9999]">
-                                                                                               <p>Unload model</p>
-                                                                                       </Tooltip.Content>
-                                                                               </Tooltip.Root>
-                                                                       {:else}
-                                                                               <span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
-                                                                       {/if}
+                                                                       <span
+                                                                               class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:pr-2 sm:text-clip sm:whitespace-nowrap"
+                                                                       >
+                                                                               {option.model}
+                                                                       </span>
+
+                                                                       <div class="flex w-6 shrink-0 justify-center">
+                                                                               {#if isLoading}
+                                                                                       <Tooltip.Root>
+                                                                                               <Tooltip.Trigger>
+                                                                                                       <Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
+                                                                                               </Tooltip.Trigger>
+                                                                                               <Tooltip.Content class="z-[9999]">
+                                                                                                       <p>Loading model...</p>
+                                                                                               </Tooltip.Content>
+                                                                                       </Tooltip.Root>
+                                                                               {:else if isLoaded}
+                                                                                       <Tooltip.Root>
+                                                                                               <Tooltip.Trigger>
+                                                                                                       <button
+                                                                                                               type="button"
+                                                                                                               class="relative flex h-4 w-4 items-center justify-center"
+                                                                                                               onclick={(e) => {
+                                                                                                                       e.stopPropagation();
+                                                                                                                       modelsStore.unloadModel(option.model);
+                                                                                                               }}
+                                                                                                       >
+                                                                                                               <span
+                                                                                                                       class="h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
+                                                                                                               ></span>
+                                                                                                               <Power
+                                                                                                                       class="absolute h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
+                                                                                                               />
+                                                                                                       </button>
+                                                                                               </Tooltip.Trigger>
+                                                                                               <Tooltip.Content class="z-[9999]">
+                                                                                                       <p>Unload model</p>
+                                                                                               </Tooltip.Content>
+                                                                                       </Tooltip.Root>
+                                                                               {:else}
+                                                                                       <span class="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
+                                                                               {/if}
+                                                                       </div>
                                                                </div>
                                                        {/each}
                                                </div>
-                                       </div>
-                               </Popover.Content>
-                       </Popover.Root>
+                                       </DropdownMenuSearchable>
+                               </DropdownMenu.Content>
+                       </DropdownMenu.Root>
                {:else}
                        <button
                                class={cn(
                        >
                                <Package class="h-3.5 w-3.5" />
 
-                               <span class="truncate font-medium">
-                                       {selectedOption?.model}
-                               </span>
+                               <TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
 
                                {#if updating}
                                        <Loader2 class="h-3 w-3.5 animate-spin" />
diff --git a/tools/server/webui/src/lib/components/app/models/index.ts b/tools/server/webui/src/lib/components/app/models/index.ts
new file mode 100644 (file)
index 0000000..bb3710d
--- /dev/null
@@ -0,0 +1,73 @@
+/**
+ *
+ * MODELS
+ *
+ * Components for model selection and display. Supports two server modes:
+ * - **Single model mode**: Server runs with one model, selector shows model info
+ * - **Router mode**: Server runs with multiple models, selector enables switching
+ *
+ * Integrates with modelsStore for model data and serverStore for mode detection.
+ *
+ */
+
+/**
+ * **ModelsSelector** - Model selection dropdown
+ *
+ * Dropdown for selecting AI models with status indicators,
+ * search, and model information display. Adapts UI based on server mode.
+ *
+ * **Architecture:**
+ * - Uses DropdownMenuSearchable for model list
+ * - Integrates with modelsStore for model options and selection
+ * - Detects router vs single mode from serverStore
+ * - Opens DialogModelInformation for model details
+ *
+ * **Features:**
+ * - Searchable model list with keyboard navigation
+ * - Model status indicators (loading/ready/error/updating)
+ * - Model capabilities badges (vision, tools, etc.)
+ * - Current/active model highlighting
+ * - Model information dialog on info button click
+ * - Router mode: shows all available models with status
+ * - Single mode: shows current model name only
+ * - Loading/updating skeleton states
+ * - Global selection support for form integration
+ *
+ * @example
+ * ```svelte
+ * <ModelsSelector
+ *   currentModel={conversation.modelId}
+ *   onModelChange={(id, name) => updateModel(id)}
+ *   useGlobalSelection
+ * />
+ * ```
+ */
+export { default as ModelsSelector } from './ModelsSelector.svelte';
+
+/**
+ * **ModelBadge** - Model name display badge
+ *
+ * Compact badge showing current model name with package icon.
+ * Only visible in single model mode. Supports tooltip and copy functionality.
+ *
+ * **Architecture:**
+ * - Reads model name from modelsStore or prop
+ * - Checks server mode from serverStore
+ * - Uses BadgeInfo for consistent styling
+ *
+ * **Features:**
+ * - Optional copy to clipboard button
+ * - Optional tooltip with model details
+ * - Click handler for model info dialog
+ * - Only renders in model mode (not router)
+ *
+ * @example
+ * ```svelte
+ * <ModelBadge
+ *   onclick={() => showModelInfo = true}
+ *   showTooltip
+ *   showCopyIcon
+ * />
+ * ```
+ */
+export { default as ModelBadge } from './ModelBadge.svelte';
index 520e5bf56fa37a3d8717b2d51bf82c1cbc1c3f7f..c7f52a7c58d76823a770465cdd952a66daf8b87e 100644 (file)
@@ -8,7 +8,7 @@
        import { serverStore, serverLoading } from '$lib/stores/server.svelte';
        import { config, settingsStore } from '$lib/stores/settings.svelte';
        import { fade, fly, scale } from 'svelte/transition';
-       import { KeyboardKey } from '$lib/enums/keyboard';
+       import { KeyboardKey } from '$lib/enums';
 
        interface Props {
                class?: string;
index eac919ad9673d41dcb5cf60ec704593b32f8cfb2..21a95cc88371759785c856564226d4ce0daf32f8 100644 (file)
@@ -1,8 +1,4 @@
-export interface BinaryDetectionOptions {
-       prefixLength: number;
-       suspiciousCharThresholdRatio: number;
-       maxAbsoluteNullBytes: number;
-}
+import type { BinaryDetectionOptions } from '$lib/types';
 
 export const DEFAULT_BINARY_DETECTION_OPTIONS: BinaryDetectionOptions = {
        prefixLength: 1024 * 10, // Check the first 10KB of the string
diff --git a/tools/server/webui/src/lib/constants/cache.ts b/tools/server/webui/src/lib/constants/cache.ts
new file mode 100644 (file)
index 0000000..acdb7a6
--- /dev/null
@@ -0,0 +1,33 @@
+/**
+ * Cache configuration constants
+ */
+
+/**
+ * Default TTL (Time-To-Live) for cache entries in milliseconds.
+ */
+export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
+
+/**
+ * Default maximum number of entries in a cache.
+ */
+export const DEFAULT_CACHE_MAX_ENTRIES = 100;
+
+/**
+ * TTL for model props cache in milliseconds.
+ */
+export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000;
+
+/**
+ * Maximum number of model props to cache.
+ */
+export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50;
+
+/**
+ * Maximum number of inactive conversation states to keep in memory.
+ */
+export const MAX_INACTIVE_CONVERSATION_STATES = 10;
+
+/**
+ * Maximum age (in ms) for inactive conversation states before cleanup.
+ */
+export const INACTIVE_CONVERSATION_STATE_MAX_AGE_MS = 30 * 60 * 1000;
index a541cfc55385667da53207108ede68e032b70ba7..2781fdbd72c226c46efd27c8e504e610f53659a1 100644 (file)
@@ -1,6 +1 @@
-export const INPUT_CLASSES = `
-    bg-muted/70 dark:bg-muted/85
-    border border-border/30 focus-within:border-border  dark:border-border/20 dark:focus-within:border-border
-    outline-none
-    text-foreground
-`;
+export { INPUT_CLASSES } from './css-classes';
diff --git a/tools/server/webui/src/lib/constants/settings-sections.ts b/tools/server/webui/src/lib/constants/settings-sections.ts
new file mode 100644 (file)
index 0000000..9d8a4db
--- /dev/null
@@ -0,0 +1,14 @@
+/**
+ * Settings section titles constants for ChatSettings component.
+ */
+export const SETTINGS_SECTION_TITLES = {
+       GENERAL: 'General',
+       DISPLAY: 'Display',
+       SAMPLING: 'Sampling',
+       PENALTIES: 'Penalties',
+       IMPORT_EXPORT: 'Import/Export',
+       DEVELOPER: 'Developer'
+} as const;
+
+export type SettingsSectionTitle =
+       (typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES];
index 83c86caf66928c06a024b446c7dc11a8344e4cae..5b39eebbb15fb37cf88eb044cea2a6eec68621c2 100644 (file)
@@ -1,6 +1,13 @@
 export { AttachmentType } from './attachment';
 
-export { ChatMessageStatsView } from './chat';
+export {
+       ChatMessageStatsView,
+       ReasoningFormat,
+       MessageRole,
+       MessageType,
+       ContentPartType,
+       ErrorDialogType
+} from './chat';
 
 export {
        FileTypeCategory,
@@ -21,3 +28,9 @@ export {
 export { ModelModality } from './model';
 
 export { ServerRole, ServerModelStatus } from './server';
+
+export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
+
+export { KeyboardKey } from './keyboard';
+
+export { UrlPrefix } from './ui';
diff --git a/tools/server/webui/src/lib/enums/ui.ts b/tools/server/webui/src/lib/enums/ui.ts
new file mode 100644 (file)
index 0000000..72a5848
--- /dev/null
@@ -0,0 +1,10 @@
+/**
+ * URL prefixes for protocol detection.
+ */
+export enum UrlPrefix {
+       DATA = 'data:',
+       HTTP = 'http://',
+       HTTPS = 'https://',
+       WEBSOCKET = 'ws://',
+       WEBSOCKET_SECURE = 'wss://'
+}
index bb666159c98d4150210c7fde64aa854058642d77..f52d8dc81701a39b12e97de1ebe54caabd6e0533 100644 (file)
@@ -1,26 +1,21 @@
 import { modelsStore } from '$lib/stores/models.svelte';
 import { isRouterMode } from '$lib/stores/server.svelte';
 import { toast } from 'svelte-sonner';
+import type { ModelModalities } from '$lib/types';
 
 interface UseModelChangeValidationOptions {
        /**
         * Function to get required modalities for validation.
-        * For ChatForm: () => usedModalities() - all messages
-        * For ChatMessageAssistant: () => getModalitiesUpToMessage(messageId) - messages before
         */
        getRequiredModalities: () => ModelModalities;
 
        /**
         * Optional callback to execute after successful validation.
-        * For ChatForm: undefined - just select model
-        * For ChatMessageAssistant: (modelName) => onRegenerate(modelName)
         */
        onSuccess?: (modelName: string) => void;
 
        /**
         * Optional callback for rollback on validation failure.
-        * For ChatForm: (previousId) => selectModelById(previousId)
-        * For ChatMessageAssistant: undefined - no rollback needed
         */
        onValidationFailure?: (previousModelId: string | null) => Promise<void>;
 }
@@ -33,12 +28,10 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
 
        async function handleModelChange(modelId: string, modelName: string): Promise<boolean> {
                try {
-                       // Store previous selection for potential rollback
                        if (onValidationFailure) {
                                previousSelectedModelId = modelsStore.selectedModelId;
                        }
 
-                       // Load model if not already loaded (router mode only)
                        let hasLoadedModel = false;
                        const isModelLoadedBefore = modelsStore.isModelLoaded(modelName);
 
@@ -52,13 +45,11 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
                                }
                        }
 
-                       // Fetch model props to validate modalities
                        const props = await modelsStore.fetchModelProps(modelName);
 
                        if (props?.modalities) {
                                const requiredModalities = getRequiredModalities();
 
-                               // Check if model supports required modalities
                                const missingModalities: string[] = [];
                                if (requiredModalities.vision && !props.modalities.vision) {
                                        missingModalities.push('vision');
@@ -72,7 +63,6 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
                                                `Model "${modelName}" doesn't support required modalities: ${missingModalities.join(', ')}. Please select a different model.`
                                        );
 
-                                       // Unload the model if we just loaded it
                                        if (isRouter && hasLoadedModel) {
                                                try {
                                                        await modelsStore.unloadModel(modelName);
@@ -81,7 +71,6 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
                                                }
                                        }
 
-                                       // Execute rollback callback if provided
                                        if (onValidationFailure && previousSelectedModelId) {
                                                await onValidationFailure(previousSelectedModelId);
                                        }
@@ -90,10 +79,8 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
                                }
                        }
 
-                       // Select the model (validation passed)
                        await modelsStore.selectModelById(modelId);
 
-                       // Execute success callback if provided
                        if (onSuccess) {
                                onSuccess(modelName);
                        }
@@ -103,7 +90,6 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
                        console.error('Failed to change model:', error);
                        toast.error('Failed to validate model capabilities');
 
-                       // Execute rollback callback on error if provided
                        if (onValidationFailure && previousSelectedModelId) {
                                await onValidationFailure(previousSelectedModelId);
                        }
index 068440cdc03b5f8347ebd654d515c3f0f05b8fd5..1205d9b97384c40da9da80ec34c8e544295dc1a5 100644 (file)
@@ -1,21 +1,7 @@
 import { activeProcessingState } from '$lib/stores/chat.svelte';
 import { config } from '$lib/stores/settings.svelte';
 import { STATS_UNITS } from '$lib/constants/processing-info';
-import type { ApiProcessingState } from '$lib/types';
-
-interface LiveProcessingStats {
-       tokensProcessed: number;
-       totalTokens: number;
-       timeMs: number;
-       tokensPerSecond: number;
-       etaSecs?: number;
-}
-
-interface LiveGenerationStats {
-       tokensGenerated: number;
-       timeMs: number;
-       tokensPerSecond: number;
-}
+import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types';
 
 export interface UseProcessingStateReturn {
        readonly processingState: ApiProcessingState | null;
diff --git a/tools/server/webui/src/lib/markdown/resolve-attachment-images.ts b/tools/server/webui/src/lib/markdown/resolve-attachment-images.ts
new file mode 100644 (file)
index 0000000..bc67ef9
--- /dev/null
@@ -0,0 +1,31 @@
+import type { Root as HastRoot } from 'hast';
+import { visit } from 'unist-util-visit';
+import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
+import { AttachmentType, UrlPrefix } from '$lib/enums';
+
+/**
+ * Rehype plugin to resolve attachment image sources.
+ * Converts attachment names to base64 data URLs.
+ */
+export function rehypeResolveAttachmentImages(options: { attachments?: DatabaseMessageExtra[] }) {
+       return (tree: HastRoot) => {
+               visit(tree, 'element', (node) => {
+                       if (node.tagName === 'img' && node.properties?.src) {
+                               const src = String(node.properties.src);
+
+                               if (src.startsWith(UrlPrefix.DATA) || src.startsWith(UrlPrefix.HTTP)) {
+                                       return;
+                               }
+
+                               const attachment = options.attachments?.find(
+                                       (a): a is DatabaseMessageExtraImageFile =>
+                                               a.type === AttachmentType.IMAGE && a.name === src
+                               );
+
+                               if (attachment?.base64Url) {
+                                       node.properties.src = attachment.base64Url;
+                               }
+                       }
+               });
+       };
+}
index 0d5a9c1b99cdc071c8c36c91b2af47c33b482c68..2592794c92bb8c83d5fcdaf97ddcb5160921038b 100644 (file)
@@ -17,7 +17,7 @@ class LlamacppDatabase extends Dexie {
 
 const db = new LlamacppDatabase();
 import { v4 as uuid } from 'uuid';
-import { MessageRole } from '$lib/enums/chat';
+import { MessageRole } from '$lib/enums';
 
 export class DatabaseService {
        /**
diff --git a/tools/server/webui/src/lib/services/database.ts b/tools/server/webui/src/lib/services/database.ts
deleted file mode 100644 (file)
index 3b24628..0000000
+++ /dev/null
@@ -1,400 +0,0 @@
-import Dexie, { type EntityTable } from 'dexie';
-import { findDescendantMessages } from '$lib/utils';
-
-class LlamacppDatabase extends Dexie {
-       conversations!: EntityTable<DatabaseConversation, string>;
-       messages!: EntityTable<DatabaseMessage, string>;
-
-       constructor() {
-               super('LlamacppWebui');
-
-               this.version(1).stores({
-                       conversations: 'id, lastModified, currNode, name',
-                       messages: 'id, convId, type, role, timestamp, parent, children'
-               });
-       }
-}
-
-const db = new LlamacppDatabase();
-import { v4 as uuid } from 'uuid';
-
-/**
- * DatabaseService - Stateless IndexedDB communication layer
- *
- * **Terminology - Chat vs Conversation:**
- * - **Chat**: The active interaction space with the Chat Completions API (ephemeral, runtime).
- * - **Conversation**: The persistent database entity storing all messages and metadata.
- *   This service handles raw database operations for conversations - the lowest layer
- *   in the persistence stack.
- *
- * This service provides a stateless data access layer built on IndexedDB using Dexie ORM.
- * It handles all low-level storage operations for conversations and messages with support
- * for complex branching and message threading. All methods are static - no instance state.
- *
- * **Architecture & Relationships (bottom to top):**
- * - **DatabaseService** (this class): Stateless IndexedDB operations
- *   - Lowest layer - direct Dexie/IndexedDB communication
- *   - Pure CRUD operations without business logic
- *   - Handles branching tree structure (parent-child relationships)
- *   - Provides transaction safety for multi-table operations
- *
- * - **ConversationsService**: Stateless business logic layer
- *   - Uses DatabaseService for all persistence operations
- *   - Adds import/export, navigation, and higher-level operations
- *
- * - **conversationsStore**: Reactive state management for conversations
- *   - Uses ConversationsService for database operations
- *   - Manages conversation list, active conversation, and messages in memory
- *
- * - **chatStore**: Active AI interaction management
- *   - Uses conversationsStore for conversation context
- *   - Directly uses DatabaseService for message CRUD during streaming
- *
- * **Key Features:**
- * - **Conversation CRUD**: Create, read, update, delete conversations
- * - **Message CRUD**: Add, update, delete messages with branching support
- * - **Branch Operations**: Create branches, find descendants, cascade deletions
- * - **Transaction Safety**: Atomic operations for data consistency
- *
- * **Database Schema:**
- * - `conversations`: id, lastModified, currNode, name
- * - `messages`: id, convId, type, role, timestamp, parent, children
- *
- * **Branching Model:**
- * Messages form a tree structure where each message can have multiple children,
- * enabling conversation branching and alternative response paths. The conversation's
- * `currNode` tracks the currently active branch endpoint.
- */
-export class DatabaseService {
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Conversations
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Creates a new conversation.
-        *
-        * @param name - Name of the conversation
-        * @returns The created conversation
-        */
-       static async createConversation(name: string): Promise<DatabaseConversation> {
-               const conversation: DatabaseConversation = {
-                       id: uuid(),
-                       name,
-                       lastModified: Date.now(),
-                       currNode: ''
-               };
-
-               await db.conversations.add(conversation);
-               return conversation;
-       }
-
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Messages
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Creates a new message branch by adding a message and updating parent/child relationships.
-        * Also updates the conversation's currNode to point to the new message.
-        *
-        * @param message - Message to add (without id)
-        * @param parentId - Parent message ID to attach to
-        * @returns The created message
-        */
-       static async createMessageBranch(
-               message: Omit<DatabaseMessage, 'id'>,
-               parentId: string | null
-       ): Promise<DatabaseMessage> {
-               return await db.transaction('rw', [db.conversations, db.messages], async () => {
-                       // Handle null parent (root message case)
-                       if (parentId !== null) {
-                               const parentMessage = await db.messages.get(parentId);
-                               if (!parentMessage) {
-                                       throw new Error(`Parent message ${parentId} not found`);
-                               }
-                       }
-
-                       const newMessage: DatabaseMessage = {
-                               ...message,
-                               id: uuid(),
-                               parent: parentId,
-                               toolCalls: message.toolCalls ?? '',
-                               children: []
-                       };
-
-                       await db.messages.add(newMessage);
-
-                       // Update parent's children array if parent exists
-                       if (parentId !== null) {
-                               const parentMessage = await db.messages.get(parentId);
-                               if (parentMessage) {
-                                       await db.messages.update(parentId, {
-                                               children: [...parentMessage.children, newMessage.id]
-                                       });
-                               }
-                       }
-
-                       await this.updateConversation(message.convId, {
-                               currNode: newMessage.id
-                       });
-
-                       return newMessage;
-               });
-       }
-
-       /**
-        * Creates a root message for a new conversation.
-        * Root messages are not displayed but serve as the tree root for branching.
-        *
-        * @param convId - Conversation ID
-        * @returns The created root message
-        */
-       static async createRootMessage(convId: string): Promise<string> {
-               const rootMessage: DatabaseMessage = {
-                       id: uuid(),
-                       convId,
-                       type: 'root',
-                       timestamp: Date.now(),
-                       role: 'system',
-                       content: '',
-                       parent: null,
-                       thinking: '',
-                       toolCalls: '',
-                       children: []
-               };
-
-               await db.messages.add(rootMessage);
-               return rootMessage.id;
-       }
-
-       /**
-        * Creates a system prompt message for a conversation.
-        *
-        * @param convId - Conversation ID
-        * @param systemPrompt - The system prompt content (must be non-empty)
-        * @param parentId - Parent message ID (typically the root message)
-        * @returns The created system message
-        * @throws Error if systemPrompt is empty
-        */
-       static async createSystemMessage(
-               convId: string,
-               systemPrompt: string,
-               parentId: string
-       ): Promise<DatabaseMessage> {
-               const trimmedPrompt = systemPrompt.trim();
-               if (!trimmedPrompt) {
-                       throw new Error('Cannot create system message with empty content');
-               }
-
-               const systemMessage: DatabaseMessage = {
-                       id: uuid(),
-                       convId,
-                       type: 'system',
-                       timestamp: Date.now(),
-                       role: 'system',
-                       content: trimmedPrompt,
-                       parent: parentId,
-                       thinking: '',
-                       children: []
-               };
-
-               await db.messages.add(systemMessage);
-
-               const parentMessage = await db.messages.get(parentId);
-               if (parentMessage) {
-                       await db.messages.update(parentId, {
-                               children: [...parentMessage.children, systemMessage.id]
-                       });
-               }
-
-               return systemMessage;
-       }
-
-       /**
-        * Deletes a conversation and all its messages.
-        *
-        * @param id - Conversation ID
-        */
-       static async deleteConversation(id: string): Promise<void> {
-               await db.transaction('rw', [db.conversations, db.messages], async () => {
-                       await db.conversations.delete(id);
-                       await db.messages.where('convId').equals(id).delete();
-               });
-       }
-
-       /**
-        * Deletes a message and removes it from its parent's children array.
-        *
-        * @param messageId - ID of the message to delete
-        */
-       static async deleteMessage(messageId: string): Promise<void> {
-               await db.transaction('rw', db.messages, async () => {
-                       const message = await db.messages.get(messageId);
-                       if (!message) return;
-
-                       // Remove this message from its parent's children array
-                       if (message.parent) {
-                               const parent = await db.messages.get(message.parent);
-                               if (parent) {
-                                       parent.children = parent.children.filter((childId: string) => childId !== messageId);
-                                       await db.messages.put(parent);
-                               }
-                       }
-
-                       // Delete the message
-                       await db.messages.delete(messageId);
-               });
-       }
-
-       /**
-        * Deletes a message and all its descendant messages (cascading deletion).
-        * This removes the entire branch starting from the specified message.
-        *
-        * @param conversationId - ID of the conversation containing the message
-        * @param messageId - ID of the root message to delete (along with all descendants)
-        * @returns Array of all deleted message IDs
-        */
-       static async deleteMessageCascading(
-               conversationId: string,
-               messageId: string
-       ): Promise<string[]> {
-               return await db.transaction('rw', db.messages, async () => {
-                       // Get all messages in the conversation to find descendants
-                       const allMessages = await db.messages.where('convId').equals(conversationId).toArray();
-
-                       // Find all descendant messages
-                       const descendants = findDescendantMessages(allMessages, messageId);
-                       const allToDelete = [messageId, ...descendants];
-
-                       // Get the message to delete for parent cleanup
-                       const message = await db.messages.get(messageId);
-                       if (message && message.parent) {
-                               const parent = await db.messages.get(message.parent);
-                               if (parent) {
-                                       parent.children = parent.children.filter((childId: string) => childId !== messageId);
-                                       await db.messages.put(parent);
-                               }
-                       }
-
-                       // Delete all messages in the branch
-                       await db.messages.bulkDelete(allToDelete);
-
-                       return allToDelete;
-               });
-       }
-
-       /**
-        * Gets all conversations, sorted by last modified time (newest first).
-        *
-        * @returns Array of conversations
-        */
-       static async getAllConversations(): Promise<DatabaseConversation[]> {
-               return await db.conversations.orderBy('lastModified').reverse().toArray();
-       }
-
-       /**
-        * Gets a conversation by ID.
-        *
-        * @param id - Conversation ID
-        * @returns The conversation if found, otherwise undefined
-        */
-       static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
-               return await db.conversations.get(id);
-       }
-
-       /**
-        * Gets all messages in a conversation, sorted by timestamp (oldest first).
-        *
-        * @param convId - Conversation ID
-        * @returns Array of messages in the conversation
-        */
-       static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
-               return await db.messages.where('convId').equals(convId).sortBy('timestamp');
-       }
-
-       /**
-        * Updates a conversation.
-        *
-        * @param id - Conversation ID
-        * @param updates - Partial updates to apply
-        * @returns Promise that resolves when the conversation is updated
-        */
-       static async updateConversation(
-               id: string,
-               updates: Partial<Omit<DatabaseConversation, 'id'>>
-       ): Promise<void> {
-               await db.conversations.update(id, {
-                       ...updates,
-                       lastModified: Date.now()
-               });
-       }
-
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Navigation
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Updates the conversation's current node (active branch).
-        * This determines which conversation path is currently being viewed.
-        *
-        * @param convId - Conversation ID
-        * @param nodeId - Message ID to set as current node
-        */
-       static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
-               await this.updateConversation(convId, {
-                       currNode: nodeId
-               });
-       }
-
-       /**
-        * Updates a message.
-        *
-        * @param id - Message ID
-        * @param updates - Partial updates to apply
-        * @returns Promise that resolves when the message is updated
-        */
-       static async updateMessage(
-               id: string,
-               updates: Partial<Omit<DatabaseMessage, 'id'>>
-       ): Promise<void> {
-               await db.messages.update(id, updates);
-       }
-
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Import
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Imports multiple conversations and their messages.
-        * Skips conversations that already exist.
-        *
-        * @param data - Array of { conv, messages } objects
-        */
-       static async importConversations(
-               data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
-       ): Promise<{ imported: number; skipped: number }> {
-               let importedCount = 0;
-               let skippedCount = 0;
-
-               return await db.transaction('rw', [db.conversations, db.messages], async () => {
-                       for (const item of data) {
-                               const { conv, messages } = item;
-
-                               const existing = await db.conversations.get(conv.id);
-                               if (existing) {
-                                       console.warn(`Conversation "${conv.name}" already exists, skipping...`);
-                                       skippedCount++;
-                                       continue;
-                               }
-
-                               await db.conversations.add(conv);
-                               for (const msg of messages) {
-                                       await db.messages.put(msg);
-                               }
-
-                               importedCount++;
-                       }
-
-                       return { imported: importedCount, skipped: skippedCount };
-               });
-       }
-}
index c36c64a6fa90e01719d22208c87d93ebb77bbecb..b59d7cec34dd85b5b68fc0fa99f41c4337b949da 100644 (file)
@@ -1,5 +1,5 @@
 export { ChatService } from './chat';
-export { DatabaseService } from './database';
-export { ModelsService } from './models';
-export { PropsService } from './props';
-export { ParameterSyncService } from './parameter-sync';
+export { DatabaseService } from './database.service';
+export { ModelsService } from './models.service';
+export { PropsService } from './props.service';
+export { ParameterSyncService, SYNCABLE_PARAMETERS } from './parameter-sync.service';
index 7357c3f400b25f2b52ab9117b26a79d3003e69aa..347f171846588c2942f88669867801320ff504df 100644 (file)
@@ -1,5 +1,5 @@
 import { ServerModelStatus } from '$lib/enums';
-import { apiFetch, apiPost } from '$lib/utils/api-fetch';
+import { apiFetch, apiPost } from '$lib/utils';
 
 export class ModelsService {
        /**
diff --git a/tools/server/webui/src/lib/services/models.ts b/tools/server/webui/src/lib/services/models.ts
deleted file mode 100644 (file)
index eecb7fa..0000000
+++ /dev/null
@@ -1,124 +0,0 @@
-import { base } from '$app/paths';
-import { ServerModelStatus } from '$lib/enums';
-import { getJsonHeaders } from '$lib/utils';
-
-/**
- * ModelsService - Stateless service for model management API communication
- *
- * This service handles communication with model-related endpoints:
- * - `/v1/models` - OpenAI-compatible model list (MODEL + ROUTER mode)
- * - `/models/load`, `/models/unload` - Router-specific model management (ROUTER mode only)
- *
- * **Responsibilities:**
- * - List available models
- * - Load/unload models (ROUTER mode)
- * - Check model status (ROUTER mode)
- *
- * **Used by:**
- * - modelsStore: Primary consumer for model state management
- */
-export class ModelsService {
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Listing
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Fetch list of models from OpenAI-compatible endpoint
-        * Works in both MODEL and ROUTER modes
-        */
-       static async list(): Promise<ApiModelListResponse> {
-               const response = await fetch(`${base}/v1/models`, {
-                       headers: getJsonHeaders()
-               });
-
-               if (!response.ok) {
-                       throw new Error(`Failed to fetch model list (status ${response.status})`);
-               }
-
-               return response.json() as Promise<ApiModelListResponse>;
-       }
-
-       /**
-        * Fetch list of all models with detailed metadata (ROUTER mode)
-        * Returns models with load status, paths, and other metadata
-        */
-       static async listRouter(): Promise<ApiRouterModelsListResponse> {
-               const response = await fetch(`${base}/v1/models`, {
-                       headers: getJsonHeaders()
-               });
-
-               if (!response.ok) {
-                       throw new Error(`Failed to fetch router models list (status ${response.status})`);
-               }
-
-               return response.json() as Promise<ApiRouterModelsListResponse>;
-       }
-
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Load/Unload
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Load a model (ROUTER mode)
-        * POST /models/load
-        * @param modelId - Model identifier to load
-        * @param extraArgs - Optional additional arguments to pass to the model instance
-        */
-       static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
-               const payload: { model: string; extra_args?: string[] } = { model: modelId };
-               if (extraArgs && extraArgs.length > 0) {
-                       payload.extra_args = extraArgs;
-               }
-
-               const response = await fetch(`${base}/models/load`, {
-                       method: 'POST',
-                       headers: getJsonHeaders(),
-                       body: JSON.stringify(payload)
-               });
-
-               if (!response.ok) {
-                       const errorData = await response.json().catch(() => ({}));
-                       throw new Error(errorData.error || `Failed to load model (status ${response.status})`);
-               }
-
-               return response.json() as Promise<ApiRouterModelsLoadResponse>;
-       }
-
-       /**
-        * Unload a model (ROUTER mode)
-        * POST /models/unload
-        * @param modelId - Model identifier to unload
-        */
-       static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
-               const response = await fetch(`${base}/models/unload`, {
-                       method: 'POST',
-                       headers: getJsonHeaders(),
-                       body: JSON.stringify({ model: modelId })
-               });
-
-               if (!response.ok) {
-                       const errorData = await response.json().catch(() => ({}));
-                       throw new Error(errorData.error || `Failed to unload model (status ${response.status})`);
-               }
-
-               return response.json() as Promise<ApiRouterModelsUnloadResponse>;
-       }
-
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Status
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Check if a model is loaded based on its metadata
-        */
-       static isModelLoaded(model: ApiModelDataEntry): boolean {
-               return model.status.value === ServerModelStatus.LOADED;
-       }
-
-       /**
-        * Check if a model is currently loading
-        */
-       static isModelLoading(model: ApiModelDataEntry): boolean {
-               return model.status.value === ServerModelStatus.LOADING;
-       }
-}
index 6cb53d12d15bd9bab364af4b99f6871e96d3bf16..1d7666e95552c9e2c352ad5865f90d275e1cd373 100644 (file)
@@ -1,22 +1,6 @@
 import { normalizeFloatingPoint } from '$lib/utils';
-import { SyncableParameterType, ParameterSource } from '$lib/enums/settings';
-
-type ParameterValue = string | number | boolean;
-type ParameterRecord = Record<string, ParameterValue>;
-
-interface ParameterInfo {
-       value: string | number | boolean;
-       source: ParameterSource;
-       serverDefault?: string | number | boolean;
-       userOverride?: string | number | boolean;
-}
-
-interface SyncableParameter {
-       key: string;
-       serverKey: string;
-       type: SyncableParameterType;
-       canSync: boolean;
-}
+import type { SyncableParameter, ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
+import { SyncableParameterType, ParameterSource } from '$lib/enums';
 
 /**
  * Mapping of webui setting keys to server parameter keys.
diff --git a/tools/server/webui/src/lib/services/parameter-sync.spec.ts b/tools/server/webui/src/lib/services/parameter-sync.spec.ts
deleted file mode 100644 (file)
index 6b5c58a..0000000
+++ /dev/null
@@ -1,148 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { ParameterSyncService } from './parameter-sync';
-
-describe('ParameterSyncService', () => {
-       describe('roundFloatingPoint', () => {
-               it('should fix JavaScript floating-point precision issues', () => {
-                       // Test the specific values from the screenshot
-                       const mockServerParams = {
-                               top_p: 0.949999988079071,
-                               min_p: 0.009999999776482582,
-                               temperature: 0.800000011920929,
-                               top_k: 40,
-                               samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature']
-                       };
-
-                       const result = ParameterSyncService.extractServerDefaults({
-                               ...mockServerParams,
-                               // Add other required fields to match the API type
-                               n_predict: 512,
-                               seed: -1,
-                               dynatemp_range: 0.0,
-                               dynatemp_exponent: 1.0,
-                               xtc_probability: 0.0,
-                               xtc_threshold: 0.1,
-                               typ_p: 1.0,
-                               repeat_last_n: 64,
-                               repeat_penalty: 1.0,
-                               presence_penalty: 0.0,
-                               frequency_penalty: 0.0,
-                               dry_multiplier: 0.0,
-                               dry_base: 1.75,
-                               dry_allowed_length: 2,
-                               dry_penalty_last_n: -1,
-                               mirostat: 0,
-                               mirostat_tau: 5.0,
-                               mirostat_eta: 0.1,
-                               stop: [],
-                               max_tokens: -1,
-                               n_keep: 0,
-                               n_discard: 0,
-                               ignore_eos: false,
-                               stream: true,
-                               logit_bias: [],
-                               n_probs: 0,
-                               min_keep: 0,
-                               grammar: '',
-                               grammar_lazy: false,
-                               grammar_triggers: [],
-                               preserved_tokens: [],
-                               chat_format: '',
-                               reasoning_format: '',
-                               reasoning_in_content: false,
-                               thinking_forced_open: false,
-                               'speculative.n_max': 0,
-                               'speculative.n_min': 0,
-                               'speculative.p_min': 0.0,
-                               timings_per_token: false,
-                               post_sampling_probs: false,
-                               lora: [],
-                               top_n_sigma: 0.0,
-                               dry_sequence_breakers: []
-                       } as ApiLlamaCppServerProps['default_generation_settings']['params']);
-
-                       // Check that the problematic floating-point values are rounded correctly
-                       expect(result.top_p).toBe(0.95);
-                       expect(result.min_p).toBe(0.01);
-                       expect(result.temperature).toBe(0.8);
-                       expect(result.top_k).toBe(40); // Integer should remain unchanged
-                       expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature');
-               });
-
-               it('should preserve non-numeric values', () => {
-                       const mockServerParams = {
-                               samplers: ['top_k', 'temperature'],
-                               max_tokens: -1,
-                               temperature: 0.7
-                       };
-
-                       const result = ParameterSyncService.extractServerDefaults({
-                               ...mockServerParams,
-                               // Minimal required fields
-                               n_predict: 512,
-                               seed: -1,
-                               dynatemp_range: 0.0,
-                               dynatemp_exponent: 1.0,
-                               top_k: 40,
-                               top_p: 0.95,
-                               min_p: 0.05,
-                               xtc_probability: 0.0,
-                               xtc_threshold: 0.1,
-                               typ_p: 1.0,
-                               repeat_last_n: 64,
-                               repeat_penalty: 1.0,
-                               presence_penalty: 0.0,
-                               frequency_penalty: 0.0,
-                               dry_multiplier: 0.0,
-                               dry_base: 1.75,
-                               dry_allowed_length: 2,
-                               dry_penalty_last_n: -1,
-                               mirostat: 0,
-                               mirostat_tau: 5.0,
-                               mirostat_eta: 0.1,
-                               stop: [],
-                               n_keep: 0,
-                               n_discard: 0,
-                               ignore_eos: false,
-                               stream: true,
-                               logit_bias: [],
-                               n_probs: 0,
-                               min_keep: 0,
-                               grammar: '',
-                               grammar_lazy: false,
-                               grammar_triggers: [],
-                               preserved_tokens: [],
-                               chat_format: '',
-                               reasoning_format: '',
-                               reasoning_in_content: false,
-                               thinking_forced_open: false,
-                               'speculative.n_max': 0,
-                               'speculative.n_min': 0,
-                               'speculative.p_min': 0.0,
-                               timings_per_token: false,
-                               post_sampling_probs: false,
-                               lora: [],
-                               top_n_sigma: 0.0,
-                               dry_sequence_breakers: []
-                       } as ApiLlamaCppServerProps['default_generation_settings']['params']);
-
-                       expect(result.samplers).toBe('top_k;temperature');
-                       expect(result.max_tokens).toBe(-1);
-                       expect(result.temperature).toBe(0.7);
-               });
-
-               it('should merge webui settings from props when provided', () => {
-                       const result = ParameterSyncService.extractServerDefaults(null, {
-                               pasteLongTextToFileLen: 0,
-                               pdfAsImage: true,
-                               renderUserContentAsMarkdown: false,
-                               theme: 'dark'
-                       });
-
-                       expect(result.pasteLongTextToFileLen).toBe(0);
-                       expect(result.pdfAsImage).toBe(true);
-                       expect(result.renderUserContentAsMarkdown).toBe(false);
-                       expect(result.theme).toBeUndefined();
-               });
-       });
-});
diff --git a/tools/server/webui/src/lib/services/parameter-sync.ts b/tools/server/webui/src/lib/services/parameter-sync.ts
deleted file mode 100644 (file)
index 3332607..0000000
+++ /dev/null
@@ -1,273 +0,0 @@
-/**
- * ParameterSyncService - Handles synchronization between server defaults and user settings
- *
- * This service manages the complex logic of merging server-provided default parameters
- * with user-configured overrides, ensuring the UI reflects the actual server state
- * while preserving user customizations.
- *
- * **Key Responsibilities:**
- * - Extract syncable parameters from server props
- * - Merge server defaults with user overrides
- * - Track parameter sources (server, user, default)
- * - Provide sync utilities for settings store integration
- */
-
-import { normalizeFloatingPoint } from '$lib/utils';
-
-export type ParameterSource = 'default' | 'custom';
-export type ParameterValue = string | number | boolean;
-export type ParameterRecord = Record<string, ParameterValue>;
-
-export interface ParameterInfo {
-       value: string | number | boolean;
-       source: ParameterSource;
-       serverDefault?: string | number | boolean;
-       userOverride?: string | number | boolean;
-}
-
-export interface SyncableParameter {
-       key: string;
-       serverKey: string;
-       type: 'number' | 'string' | 'boolean';
-       canSync: boolean;
-}
-
-/**
- * Mapping of webui setting keys to server parameter keys
- * Only parameters that should be synced from server are included
- */
-export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
-       { key: 'temperature', serverKey: 'temperature', type: 'number', canSync: true },
-       { key: 'top_k', serverKey: 'top_k', type: 'number', canSync: true },
-       { key: 'top_p', serverKey: 'top_p', type: 'number', canSync: true },
-       { key: 'min_p', serverKey: 'min_p', type: 'number', canSync: true },
-       { key: 'dynatemp_range', serverKey: 'dynatemp_range', type: 'number', canSync: true },
-       { key: 'dynatemp_exponent', serverKey: 'dynatemp_exponent', type: 'number', canSync: true },
-       { key: 'xtc_probability', serverKey: 'xtc_probability', type: 'number', canSync: true },
-       { key: 'xtc_threshold', serverKey: 'xtc_threshold', type: 'number', canSync: true },
-       { key: 'typ_p', serverKey: 'typ_p', type: 'number', canSync: true },
-       { key: 'repeat_last_n', serverKey: 'repeat_last_n', type: 'number', canSync: true },
-       { key: 'repeat_penalty', serverKey: 'repeat_penalty', type: 'number', canSync: true },
-       { key: 'presence_penalty', serverKey: 'presence_penalty', type: 'number', canSync: true },
-       { key: 'frequency_penalty', serverKey: 'frequency_penalty', type: 'number', canSync: true },
-       { key: 'dry_multiplier', serverKey: 'dry_multiplier', type: 'number', canSync: true },
-       { key: 'dry_base', serverKey: 'dry_base', type: 'number', canSync: true },
-       { key: 'dry_allowed_length', serverKey: 'dry_allowed_length', type: 'number', canSync: true },
-       { key: 'dry_penalty_last_n', serverKey: 'dry_penalty_last_n', type: 'number', canSync: true },
-       { key: 'max_tokens', serverKey: 'max_tokens', type: 'number', canSync: true },
-       { key: 'samplers', serverKey: 'samplers', type: 'string', canSync: true },
-       {
-               key: 'pasteLongTextToFileLen',
-               serverKey: 'pasteLongTextToFileLen',
-               type: 'number',
-               canSync: true
-       },
-       { key: 'pdfAsImage', serverKey: 'pdfAsImage', type: 'boolean', canSync: true },
-       {
-               key: 'showThoughtInProgress',
-               serverKey: 'showThoughtInProgress',
-               type: 'boolean',
-               canSync: true
-       },
-       { key: 'showToolCalls', serverKey: 'showToolCalls', type: 'boolean', canSync: true },
-       { key: 'keepStatsVisible', serverKey: 'keepStatsVisible', type: 'boolean', canSync: true },
-       { key: 'showMessageStats', serverKey: 'showMessageStats', type: 'boolean', canSync: true },
-       {
-               key: 'askForTitleConfirmation',
-               serverKey: 'askForTitleConfirmation',
-               type: 'boolean',
-               canSync: true
-       },
-       { key: 'disableAutoScroll', serverKey: 'disableAutoScroll', type: 'boolean', canSync: true },
-       {
-               key: 'renderUserContentAsMarkdown',
-               serverKey: 'renderUserContentAsMarkdown',
-               type: 'boolean',
-               canSync: true
-       },
-       { key: 'autoMicOnEmpty', serverKey: 'autoMicOnEmpty', type: 'boolean', canSync: true },
-       {
-               key: 'pyInterpreterEnabled',
-               serverKey: 'pyInterpreterEnabled',
-               type: 'boolean',
-               canSync: true
-       },
-       {
-               key: 'enableContinueGeneration',
-               serverKey: 'enableContinueGeneration',
-               type: 'boolean',
-               canSync: true
-       }
-];
-
-export class ParameterSyncService {
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Extraction
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Round floating-point numbers to avoid JavaScript precision issues
-        */
-       private static roundFloatingPoint(value: ParameterValue): ParameterValue {
-               return normalizeFloatingPoint(value) as ParameterValue;
-       }
-
-       /**
-        * Extract server default parameters that can be synced
-        */
-       static extractServerDefaults(
-               serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
-               webuiSettings?: Record<string, string | number | boolean>
-       ): ParameterRecord {
-               const extracted: ParameterRecord = {};
-
-               if (serverParams) {
-                       for (const param of SYNCABLE_PARAMETERS) {
-                               if (param.canSync && param.serverKey in serverParams) {
-                                       const value = (serverParams as unknown as Record<string, ParameterValue>)[
-                                               param.serverKey
-                                       ];
-                                       if (value !== undefined) {
-                                               // Apply precision rounding to avoid JavaScript floating-point issues
-                                               extracted[param.key] = this.roundFloatingPoint(value);
-                                       }
-                               }
-                       }
-
-                       // Handle samplers array conversion to string
-                       if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
-                               extracted.samplers = serverParams.samplers.join(';');
-                       }
-               }
-
-               if (webuiSettings) {
-                       for (const param of SYNCABLE_PARAMETERS) {
-                               if (param.canSync && param.serverKey in webuiSettings) {
-                                       const value = webuiSettings[param.serverKey];
-                                       if (value !== undefined) {
-                                               extracted[param.key] = this.roundFloatingPoint(value);
-                                       }
-                               }
-                       }
-               }
-
-               return extracted;
-       }
-
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Merging
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Merge server defaults with current user settings
-        * Returns updated settings that respect user overrides while using server defaults
-        */
-       static mergeWithServerDefaults(
-               currentSettings: ParameterRecord,
-               serverDefaults: ParameterRecord,
-               userOverrides: Set<string> = new Set()
-       ): ParameterRecord {
-               const merged = { ...currentSettings };
-
-               for (const [key, serverValue] of Object.entries(serverDefaults)) {
-                       // Only update if user hasn't explicitly overridden this parameter
-                       if (!userOverrides.has(key)) {
-                               merged[key] = this.roundFloatingPoint(serverValue);
-                       }
-               }
-
-               return merged;
-       }
-
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Info
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Get parameter information including source and values
-        */
-       static getParameterInfo(
-               key: string,
-               currentValue: ParameterValue,
-               propsDefaults: ParameterRecord,
-               userOverrides: Set<string>
-       ): ParameterInfo {
-               const hasPropsDefault = propsDefaults[key] !== undefined;
-               const isUserOverride = userOverrides.has(key);
-
-               // Simple logic: either using default (from props) or custom (user override)
-               const source: ParameterSource = isUserOverride ? 'custom' : 'default';
-
-               return {
-                       value: currentValue,
-                       source,
-                       serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
-                       userOverride: isUserOverride ? currentValue : undefined
-               };
-       }
-
-       /**
-        * Check if a parameter can be synced from server
-        */
-       static canSyncParameter(key: string): boolean {
-               return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
-       }
-
-       /**
-        * Get all syncable parameter keys
-        */
-       static getSyncableParameterKeys(): string[] {
-               return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
-       }
-
-       /**
-        * Validate server parameter value
-        */
-       static validateServerParameter(key: string, value: ParameterValue): boolean {
-               const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
-               if (!param) return false;
-
-               switch (param.type) {
-                       case 'number':
-                               return typeof value === 'number' && !isNaN(value);
-                       case 'string':
-                               return typeof value === 'string';
-                       case 'boolean':
-                               return typeof value === 'boolean';
-                       default:
-                               return false;
-               }
-       }
-
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Diff
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Create a diff between current settings and server defaults
-        */
-       static createParameterDiff(
-               currentSettings: ParameterRecord,
-               serverDefaults: ParameterRecord
-       ): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
-               const diff: Record<
-                       string,
-                       { current: ParameterValue; server: ParameterValue; differs: boolean }
-               > = {};
-
-               for (const key of this.getSyncableParameterKeys()) {
-                       const currentValue = currentSettings[key];
-                       const serverValue = serverDefaults[key];
-
-                       if (serverValue !== undefined) {
-                               diff[key] = {
-                                       current: currentValue,
-                                       server: serverValue,
-                                       differs: currentValue !== serverValue
-                               };
-                       }
-               }
-
-               return diff;
-       }
-}
index 7373b7e016a6eef952b8881f2a50b3dbbf14d33d..45c3e457732c1b7bb17f36f3c9583aee22b5eb47 100644 (file)
@@ -1,4 +1,4 @@
-import { apiFetchWithParams } from '$lib/utils/api-fetch';
+import { apiFetchWithParams } from '$lib/utils';
 
 export class PropsService {
        /**
diff --git a/tools/server/webui/src/lib/services/props.ts b/tools/server/webui/src/lib/services/props.ts
deleted file mode 100644 (file)
index 01fead9..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-import { getAuthHeaders } from '$lib/utils';
-
-/**
- * PropsService - Server properties management
- *
- * This service handles communication with the /props endpoint to retrieve
- * server configuration, model information, and capabilities.
- *
- * **Responsibilities:**
- * - Fetch server properties from /props endpoint
- * - Handle API authentication
- * - Parse and validate server response
- *
- * **Used by:**
- * - serverStore: Primary consumer for server state management
- */
-export class PropsService {
-       // ─────────────────────────────────────────────────────────────────────────────
-       // Fetching
-       // ─────────────────────────────────────────────────────────────────────────────
-
-       /**
-        * Fetches server properties from the /props endpoint
-        *
-        * @param autoload - If false, prevents automatic model loading (default: false)
-        * @returns {Promise<ApiLlamaCppServerProps>} Server properties
-        * @throws {Error} If the request fails or returns invalid data
-        */
-       static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
-               const url = new URL('./props', window.location.href);
-               if (!autoload) {
-                       url.searchParams.set('autoload', 'false');
-               }
-
-               const response = await fetch(url.toString(), {
-                       headers: getAuthHeaders()
-               });
-
-               if (!response.ok) {
-                       throw new Error(
-                               `Failed to fetch server properties: ${response.status} ${response.statusText}`
-                       );
-               }
-
-               const data = await response.json();
-               return data as ApiLlamaCppServerProps;
-       }
-
-       /**
-        * Fetches server properties for a specific model (ROUTER mode)
-        *
-        * @param modelId - The model ID to fetch properties for
-        * @param autoload - If false, prevents automatic model loading (default: false)
-        * @returns {Promise<ApiLlamaCppServerProps>} Server properties for the model
-        * @throws {Error} If the request fails or returns invalid data
-        */
-       static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
-               const url = new URL('./props', window.location.href);
-               url.searchParams.set('model', modelId);
-               if (!autoload) {
-                       url.searchParams.set('autoload', 'false');
-               }
-
-               const response = await fetch(url.toString(), {
-                       headers: getAuthHeaders()
-               });
-
-               if (!response.ok) {
-                       throw new Error(
-                               `Failed to fetch model properties: ${response.status} ${response.statusText}`
-                       );
-               }
-
-               const data = await response.json();
-               return data as ApiLlamaCppServerProps;
-       }
-}
index f00f418b4cb4a47cb5a61a358bc7c6af5d2e6d38..362e6d44b319a678cf1a790cc681712c8dbcfa0a 100644 (file)
@@ -137,6 +137,7 @@ class ChatStore {
        clearUIState(): void {
                this.isLoading = false;
                this.currentResponse = '';
+               this.isStreamingActive = false;
        }
 
        // ─────────────────────────────────────────────────────────────────────────────
index 3300eb3113930546b9ab216053eaa709896c464d..1d1c6f16a1ca93477891b66bf4793b87384eb5f4 100644 (file)
@@ -1,7 +1,7 @@
 import { browser } from '$app/environment';
 import { goto } from '$app/navigation';
 import { toast } from 'svelte-sonner';
-import { DatabaseService } from '$lib/services/database';
+import { DatabaseService } from '$lib/services/database.service';
 import { config } from '$lib/stores/settings.svelte';
 import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
 import { AttachmentType } from '$lib/enums';
@@ -241,7 +241,9 @@ class ConversationsStore {
 
                const leafNodeId =
                        this.activeConversation.currNode ||
-                       allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
+                       allMessages.reduce((latest: DatabaseMessage, msg: DatabaseMessage) =>
+                               msg.timestamp > latest.timestamp ? msg : latest
+                       ).id;
 
                const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
 
@@ -341,9 +343,11 @@ class ConversationsStore {
                if (!this.activeConversation) return;
 
                const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
-               const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
+               const rootMessage = allMessages.find(
+                       (m: DatabaseMessage) => m.type === 'root' && m.parent === null
+               );
                const currentFirstUserMessage = this.activeMessages.find(
-                       (m) => m.role === 'user' && m.parent === rootMessage?.id
+                       (m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage?.id
                );
 
                const currentLeafNodeId = findLeafNode(allMessages, siblingId);
@@ -355,7 +359,7 @@ class ConversationsStore {
                // Only show title dialog if we're navigating between different first user message siblings
                if (rootMessage && this.activeMessages.length > 0) {
                        const newFirstUserMessage = this.activeMessages.find(
-                               (m) => m.role === 'user' && m.parent === rootMessage.id
+                               (m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage.id
                        );
 
                        if (
@@ -452,7 +456,7 @@ class ConversationsStore {
                }
 
                const allData = await Promise.all(
-                       allConversations.map(async (conv) => {
+                       allConversations.map(async (conv: DatabaseConversation) => {
                                const messages = await DatabaseService.getConversationMessages(conv.id);
                                return { conv, messages };
                        })
index 34b26403e4e6f7856752b1e725d0cbe7d9bc5e23..0a35cd44ac76a740975f0d18092949cd6c211b78 100644 (file)
@@ -1,6 +1,6 @@
 import { SvelteSet } from 'svelte/reactivity';
-import { ModelsService } from '$lib/services/models';
-import { PropsService } from '$lib/services/props';
+import { ModelsService } from '$lib/services/models.service';
+import { PropsService } from '$lib/services/props.service';
 import { ServerModelStatus, ModelModality } from '$lib/enums';
 import { serverStore } from '$lib/stores/server.svelte';
 
index facfd333b613bcc59a8bf00432fa287bf287e140..7bac9ca156b5bc1da61961eee11f65bfa39d5e42 100644 (file)
@@ -1,4 +1,4 @@
-import { PropsService } from '$lib/services/props';
+import { PropsService } from '$lib/services/props.service';
 import { ServerRole } from '$lib/enums';
 
 /**
index cda940ba7e842cde95d6ec66768ef52d7e947eba..5fb5055d8c95227396e0b02c20565ef9903bf16f 100644 (file)
@@ -33,7 +33,7 @@
 
 import { browser } from '$app/environment';
 import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
-import { ParameterSyncService } from '$lib/services/parameter-sync';
+import { ParameterSyncService } from '$lib/services/parameter-sync.service';
 import { serverStore } from '$lib/stores/server.svelte';
 import {
        configToParameterRecord,
index 0e706b72b66014e8d0459c8baa508df9bbda4788..8d4661960a60377cb93f39720c85904eb60a735f 100644 (file)
@@ -1,3 +1,6 @@
+import type { ErrorDialogType } from '$lib/enums';
+import type { DatabaseMessage, DatabaseMessageExtra } from './database';
+
 export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
 export type ChatRole = 'user' | 'assistant' | 'system';
 
@@ -9,6 +12,8 @@ export interface ChatUploadedFile {
        file: File;
        preview?: string;
        textContent?: string;
+       isLoading?: boolean;
+       loadError?: string;
 }
 
 export interface ChatAttachmentDisplayItem {
@@ -17,6 +22,8 @@ export interface ChatAttachmentDisplayItem {
        size?: number;
        preview?: string;
        isImage: boolean;
+       isLoading?: boolean;
+       loadError?: string;
        uploadedFile?: ChatUploadedFile;
        attachment?: DatabaseMessageExtra;
        attachmentIndex?: number;
@@ -53,3 +60,49 @@ export interface ChatMessageTimings {
        prompt_ms?: number;
        prompt_n?: number;
 }
+
+export interface ChatStreamCallbacks {
+       onChunk?: (chunk: string) => void;
+       onReasoningChunk?: (chunk: string) => void;
+       onToolCallChunk?: (chunk: string) => void;
+       onAttachments?: (extras: DatabaseMessageExtra[]) => void;
+       onModel?: (model: string) => void;
+       onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
+       onComplete?: (
+               content?: string,
+               reasoningContent?: string,
+               timings?: ChatMessageTimings,
+               toolCallContent?: string
+       ) => void;
+       onError?: (error: Error) => void;
+}
+
+export interface ErrorDialogState {
+       type: ErrorDialogType;
+       message: string;
+       contextInfo?: { n_prompt_tokens: number; n_ctx: number };
+}
+
+export interface LiveProcessingStats {
+       tokensProcessed: number;
+       totalTokens: number;
+       timeMs: number;
+       tokensPerSecond: number;
+       etaSecs?: number;
+}
+
+export interface LiveGenerationStats {
+       tokensGenerated: number;
+       timeMs: number;
+       tokensPerSecond: number;
+}
+
+export interface AttachmentDisplayItemsOptions {
+       uploadedFiles?: ChatUploadedFile[];
+       attachments?: DatabaseMessageExtra[];
+}
+
+export interface FileProcessingResult {
+       extras: DatabaseMessageExtra[];
+       emptyFiles: string[];
+}
diff --git a/tools/server/webui/src/lib/types/common.d.ts b/tools/server/webui/src/lib/types/common.d.ts
new file mode 100644 (file)
index 0000000..a4ae12f
--- /dev/null
@@ -0,0 +1,35 @@
+import type { AttachmentType } from '$lib/enums';
+
+/**
+ * Represents a key-value pair.
+ */
+export interface KeyValuePair {
+       key: string;
+       value: string;
+}
+
+/**
+ * Binary detection configuration options.
+ */
+export interface BinaryDetectionOptions {
+       prefixLength: number;
+       suspiciousCharThresholdRatio: number;
+       maxAbsoluteNullBytes: number;
+}
+
+/**
+ * Format for text attachments when copied to clipboard.
+ */
+export interface ClipboardTextAttachment {
+       type: typeof AttachmentType.TEXT;
+       name: string;
+       content: string;
+}
+
+/**
+ * Parsed result from clipboard content.
+ */
+export interface ParsedClipboardContent {
+       message: string;
+       textAttachments: ClipboardTextAttachment[];
+}
index 2a21c6dcfaf6ef3b391bf3d71c46de85017ac6c7..7b1bba717d7c70c935503ba4b5953a4ff0f78b91 100644 (file)
@@ -41,7 +41,13 @@ export type {
        ChatAttachmentPreviewItem,
        ChatMessageSiblingInfo,
        ChatMessagePromptProgress,
-       ChatMessageTimings
+       ChatMessageTimings,
+       ChatStreamCallbacks,
+       ErrorDialogState,
+       LiveProcessingStats,
+       LiveGenerationStats,
+       AttachmentDisplayItemsOptions,
+       FileProcessingResult
 } from './chat';
 
 // Database types
@@ -59,12 +65,24 @@ export type {
 } from './database';
 
 // Model types
-export type { ModelModalities, ModelOption } from './models';
+export type { ModelModalities, ModelOption, ModalityCapabilities } from './models';
 
 // Settings types
 export type {
        SettingsConfigValue,
        SettingsFieldConfig,
        SettingsChatServiceOptions,
-       SettingsConfigType
+       SettingsConfigType,
+       ParameterValue,
+       ParameterRecord,
+       ParameterInfo,
+       SyncableParameter
 } from './settings';
+
+// Common types
+export type {
+       KeyValuePair,
+       BinaryDetectionOptions,
+       ClipboardTextAttachment,
+       ParsedClipboardContent
+} from './common';
index d894245ec3b44e83531074800d15e3ba63722a2a..eca6d8c4dab6b75a98cd7a9130ff84569e7d7ae4 100644 (file)
@@ -1,12 +1,14 @@
 import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
-import type { ChatMessageTimings } from './chat';
+import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
+import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
+import type { DatabaseMessageExtra } from './database';
 
 export type SettingsConfigValue = string | number | boolean;
 
 export interface SettingsFieldConfig {
        key: string;
        label: string;
-       type: 'input' | 'textarea' | 'checkbox' | 'select';
+       type: SettingsFieldType;
        isExperimental?: boolean;
        help?: string;
        options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
@@ -51,6 +53,7 @@ export interface SettingsChatServiceOptions {
        onChunk?: (chunk: string) => void;
        onReasoningChunk?: (chunk: string) => void;
        onToolCallChunk?: (chunk: string) => void;
+       onAttachments?: (extras: DatabaseMessageExtra[]) => void;
        onModel?: (model: string) => void;
        onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
        onComplete?: (
@@ -65,3 +68,20 @@ export interface SettingsChatServiceOptions {
 export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & {
        [key: string]: SettingsConfigValue;
 };
+
+export type ParameterValue = string | number | boolean;
+export type ParameterRecord = Record<string, ParameterValue>;
+
+export interface ParameterInfo {
+       value: ParameterValue;
+       source: ParameterSource;
+       serverDefault?: ParameterValue;
+       userOverride?: ParameterValue;
+}
+
+export interface SyncableParameter {
+       key: string;
+       serverKey: string;
+       type: SyncableParameterType;
+       canSync: boolean;
+}
index 28757a966ff5bb0f9fca71149b4c662bc431cd2e..7d12a3427617ab5e9859acac771f2d9e3b298666 100644 (file)
@@ -1,5 +1,6 @@
 import { base } from '$app/paths';
 import { getJsonHeaders, getAuthHeaders } from './api-headers';
+import { UrlPrefix } from '$lib/enums';
 
 /**
  * API Fetch Utilities
@@ -48,7 +49,8 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
        const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
        const headers = { ...baseHeaders, ...customHeaders };
 
-       const url = path.startsWith('http://') || path.startsWith('https://') ? path : `${base}${path}`;
+       const url =
+               path.startsWith(UrlPrefix.HTTP) || path.startsWith(UrlPrefix.HTTPS) ? path : `${base}${path}`;
 
        const response = await fetch(url, {
                ...fetchOptions,
index ee3a505eedbff3f3d27ff7b025f35edb3727479c..e60fb206f7d06e8ef565a6d6b40e402017ffb587 100644 (file)
@@ -15,7 +15,7 @@
  *        └── message 5 (assistant)
  */
 
-import { MessageRole } from '$lib/enums/chat';
+import { MessageRole } from '$lib/enums';
 
 /**
  * Filters messages to get the conversation path from root to a specific leaf node.
index 9d1f005822c589d1d72f1a523cc820dacb5cbc37..9a69501d0f3a34b8ac07da220f793ebcbc5f7333 100644 (file)
@@ -1,5 +1,4 @@
-const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
-const DEFAULT_CACHE_MAX_ENTRIES = 100;
+import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants/cache';
 
 /**
  * TTL Cache - Time-To-Live cache implementation for memory optimization
index 02fb4e4a365ff2a8961fef0c236bd3873cea8028..9b52e93dbe1aa7f219eac784f278f2ab2f0d269b 100644 (file)
@@ -5,7 +5,7 @@
 
 import { getFileTypeCategory } from '$lib/utils';
 import { FileTypeCategory } from '$lib/enums';
-import type { ModalityCapabilities } from '$lib/types/models';
+import type { ModalityCapabilities } from '$lib/types';
 
 /**
  * Check if a file type is supported by the given modalities
index 2f1a575d1dd1294a2f2e29f8ce6359b45b4ab276..b7fdd4038cb4f7d50e159b7910aada075537de56 100644 (file)
@@ -4,7 +4,7 @@
  */
 
 import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants/binary-detection';
-import type { BinaryDetectionOptions } from '$lib/constants/binary-detection';
+import type { BinaryDetectionOptions } from '$lib/types';
 import { FileExtensionText } from '$lib/enums';
 
 /**
diff --git a/tools/server/webui/tests/stories/ChatForm.stories.svelte b/tools/server/webui/tests/stories/ChatForm.stories.svelte
deleted file mode 100644 (file)
index a8a4c21..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-<script module lang="ts">
-       import { defineMeta } from '@storybook/addon-svelte-csf';
-       import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
-       import { expect } from 'storybook/test';
-       import jpgAsset from './fixtures/assets/1.jpg?url';
-       import svgAsset from './fixtures/assets/hf-logo.svg?url';
-       import pdfAsset from './fixtures/assets/example.pdf?raw';
-
-       const { Story } = defineMeta({
-               title: 'Components/ChatScreen/ChatForm',
-               component: ChatForm,
-               parameters: {
-                       layout: 'centered'
-               }
-       });
-
-       let fileAttachments = $state([
-               {
-                       id: '1',
-                       name: '1.jpg',
-                       type: 'image/jpeg',
-                       size: 44891,
-                       preview: jpgAsset,
-                       file: new File([''], '1.jpg', { type: 'image/jpeg' })
-               },
-               {
-                       id: '2',
-                       name: 'hf-logo.svg',
-                       type: 'image/svg+xml',
-                       size: 1234,
-                       preview: svgAsset,
-                       file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' })
-               },
-               {
-                       id: '3',
-                       name: 'example.pdf',
-                       type: 'application/pdf',
-                       size: 351048,
-                       file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' })
-               }
-       ]);
-</script>
-
-<Story
-       name="Default"
-       args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
-       play={async ({ canvas, userEvent }) => {
-               const textarea = await canvas.findByRole('textbox');
-               const submitButton = await canvas.findByRole('button', { name: 'Send' });
-
-               // Expect the input to be focused after the component is mounted
-               await expect(textarea).toHaveFocus();
-
-               // Expect the submit button to be disabled
-               await expect(submitButton).toBeDisabled();
-
-               const text = 'What is the meaning of life?';
-
-               await userEvent.clear(textarea);
-               await userEvent.type(textarea, text);
-
-               await expect(textarea).toHaveValue(text);
-
-               const fileInput = document.querySelector('input[type="file"]');
-               await expect(fileInput).not.toHaveAttribute('accept');
-       }}
-/>
-
-<Story name="Loading" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }} />
-
-<Story
-       name="FileAttachments"
-       args={{
-               class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
-               uploadedFiles: fileAttachments
-       }}
-       play={async ({ canvas }) => {
-               const jpgAttachment = canvas.getByAltText('1.jpg');
-               const svgAttachment = canvas.getByAltText('hf-logo.svg');
-               const pdfFileExtension = canvas.getByText('PDF');
-               const pdfAttachment = canvas.getByText('example.pdf');
-               const pdfSize = canvas.getByText('342.82 KB');
-
-               await expect(jpgAttachment).toBeInTheDocument();
-               await expect(jpgAttachment).toHaveAttribute('src', jpgAsset);
-
-               await expect(svgAttachment).toBeInTheDocument();
-               await expect(svgAttachment).toHaveAttribute('src', svgAsset);
-
-               await expect(pdfFileExtension).toBeInTheDocument();
-               await expect(pdfAttachment).toBeInTheDocument();
-               await expect(pdfSize).toBeInTheDocument();
-       }}
-/>
diff --git a/tools/server/webui/tests/stories/ChatScreenForm.stories.svelte b/tools/server/webui/tests/stories/ChatScreenForm.stories.svelte
new file mode 100644 (file)
index 0000000..f3cbde2
--- /dev/null
@@ -0,0 +1,96 @@
+<script module lang="ts">
+       import { defineMeta } from '@storybook/addon-svelte-csf';
+       import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
+       import { expect } from 'storybook/test';
+       import jpgAsset from './fixtures/assets/1.jpg?url';
+       import svgAsset from './fixtures/assets/hf-logo.svg?url';
+       import pdfAsset from './fixtures/assets/example.pdf?raw';
+
+       const { Story } = defineMeta({
+               title: 'Components/ChatScreen/ChatScreenForm',
+               component: ChatScreenForm,
+               parameters: {
+                       layout: 'centered'
+               }
+       });
+
+       let fileAttachments = $state([
+               {
+                       id: '1',
+                       name: '1.jpg',
+                       type: 'image/jpeg',
+                       size: 44891,
+                       preview: jpgAsset,
+                       file: new File([''], '1.jpg', { type: 'image/jpeg' })
+               },
+               {
+                       id: '2',
+                       name: 'hf-logo.svg',
+                       type: 'image/svg+xml',
+                       size: 1234,
+                       preview: svgAsset,
+                       file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' })
+               },
+               {
+                       id: '3',
+                       name: 'example.pdf',
+                       type: 'application/pdf',
+                       size: 351048,
+                       file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' })
+               }
+       ]);
+</script>
+
+<Story
+       name="Default"
+       args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+       play={async (context) => {
+               const { canvas, userEvent } = context;
+               const textarea = await canvas.findByRole('textbox');
+               const submitButton = await canvas.findByRole('button', { name: 'Send' });
+
+               // Expect the input to be focused after the component is mounted
+               await expect(textarea).toHaveFocus();
+
+               // Expect the submit button to be disabled
+               await expect(submitButton).toBeDisabled();
+
+               const text = 'What is the meaning of life?';
+
+               await userEvent.clear(textarea);
+               await userEvent.type(textarea, text);
+
+               await expect(textarea).toHaveValue(text);
+
+               const fileInput = document.querySelector('input[type="file"]');
+               await expect(fileInput).not.toHaveAttribute('accept');
+       }}
+/>
+
+<Story name="Loading" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }} />
+
+<Story
+       name="FileAttachments"
+       args={{
+               class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
+               uploadedFiles: fileAttachments
+       }}
+       play={async (context) => {
+               const { canvas } = context;
+               const jpgAttachment = canvas.getByAltText('1.jpg');
+               const svgAttachment = canvas.getByAltText('hf-logo.svg');
+               const pdfFileExtension = canvas.getByText('PDF');
+               const pdfAttachment = canvas.getByText('example.pdf');
+               const pdfSize = canvas.getByText('342.82 KB');
+
+               await expect(jpgAttachment).toBeInTheDocument();
+               await expect(jpgAttachment).toHaveAttribute('src', jpgAsset);
+
+               await expect(svgAttachment).toBeInTheDocument();
+               await expect(svgAttachment).toHaveAttribute('src', svgAsset);
+
+               await expect(pdfFileExtension).toBeInTheDocument();
+               await expect(pdfAttachment).toBeInTheDocument();
+               await expect(pdfSize).toBeInTheDocument();
+       }}
+/>
index 90aa90bb046aeadc715d7f61e031277f403ce823..04f270a432e86be1a707553bf4bfe8018dfe246f 100644 (file)
@@ -68,18 +68,22 @@ You can also test inline links like https://example.com or https://docs.python.o
 All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`,
                class: 'max-w-[56rem] w-[calc(100vw-2rem)]'
        }}
-       play={async ({ canvasElement }) => {
+       play={async (context) => {
+               const { canvasElement } = context;
                // Wait for component to render
                await new Promise((resolve) => setTimeout(resolve, 100));
 
                // Find all links in the rendered content
-               const links = canvasElement.querySelectorAll('a[href]');
+               const links = (canvasElement as HTMLElement).querySelectorAll(
+                       'a[href]'
+               ) as NodeListOf<HTMLAnchorElement>;
+               const linkList = Array.from(links) as HTMLAnchorElement[];
 
                // Test that we have the expected number of links
                expect(links.length).toBeGreaterThan(0);
 
                // Test each link for proper attributes
-               links.forEach((link) => {
+               links.forEach((link: HTMLAnchorElement) => {
                        const href = link.getAttribute('href');
 
                        // Test that external links have proper security attributes
@@ -90,37 +94,35 @@ All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attr
                });
 
                // Test specific links exist
-               const hugginFaceLink = Array.from(links).find(
+               const hugginFaceLink = linkList.find(
                        (link) => link.getAttribute('href') === 'https://huggingface.co'
                );
                expect(hugginFaceLink).toBeTruthy();
                expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage');
 
-               const githubLink = Array.from(links).find(
+               const githubLink = linkList.find(
                        (link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp'
                );
                expect(githubLink).toBeTruthy();
                expect(githubLink?.textContent).toBe('GitHub Repository');
 
-               const openaiLink = Array.from(links).find(
-                       (link) => link.getAttribute('href') === 'https://openai.com'
-               );
+               const openaiLink = linkList.find((link) => link.getAttribute('href') === 'https://openai.com');
                expect(openaiLink).toBeTruthy();
                expect(openaiLink?.textContent).toBe('OpenAI Website');
 
-               const googleLink = Array.from(links).find(
+               const googleLink = linkList.find(
                        (link) => link.getAttribute('href') === 'https://www.google.com'
                );
                expect(googleLink).toBeTruthy();
                expect(googleLink?.textContent).toBe('Google Search');
 
                // Test inline links (auto-linked URLs)
-               const exampleLink = Array.from(links).find(
+               const exampleLink = linkList.find(
                        (link) => link.getAttribute('href') === 'https://example.com'
                );
                expect(exampleLink).toBeTruthy();
 
-               const pythonDocsLink = Array.from(links).find(
+               const pythonDocsLink = linkList.find(
                        (link) => link.getAttribute('href') === 'https://docs.python.org'
                );
                expect(pythonDocsLink).toBeTruthy();
index 5183c09fcac76f8564ae0a4ca62df42f318d48b3..e4408f09e41c9c882ac512640e9650b76f3dcb09 100644 (file)
@@ -2,11 +2,15 @@ import tailwindcss from '@tailwindcss/vite';
 import { sveltekit } from '@sveltejs/kit/vite';
 import * as fflate from 'fflate';
 import { readFileSync, writeFileSync, existsSync } from 'fs';
-import { resolve } from 'path';
-import { defineConfig } from 'vite';
+import { dirname, resolve } from 'path';
+import { fileURLToPath } from 'url';
+
+import { defineConfig, searchForWorkspaceRoot } from 'vite';
 import devtoolsJson from 'vite-plugin-devtools-json';
 import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
 
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
 const GUIDE_FOR_FRONTEND = `
 <!--
   This is a single file build of the frontend.
@@ -156,11 +160,15 @@ export default defineConfig({
                proxy: {
                        '/v1': 'http://localhost:8080',
                        '/props': 'http://localhost:8080',
-                       '/models': 'http://localhost:8080'
+                       '/models': 'http://localhost:8080',
+                       '/cors-proxy': 'http://localhost:8080'
                },
                headers: {
                        'Cross-Origin-Embedder-Policy': 'require-corp',
                        'Cross-Origin-Opener-Policy': 'same-origin'
+               },
+               fs: {
+                       allow: [searchForWorkspaceRoot(process.cwd()), resolve(__dirname, 'tests')]
                }
        }
 });