]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
Improved file naming & structure for UI components (#17405)
authorAleksander Grygier <redacted>
Thu, 20 Nov 2025 13:07:31 +0000 (14:07 +0100)
committerGitHub <redacted>
Thu, 20 Nov 2025 13:07:31 +0000 (14:07 +0100)
* refactor: Component iles naming & structure

* chore: update webui build output

* refactor: Dialog titles + components namig

* chore: update webui build output

* refactor: Imports

* chore: update webui build output

47 files changed:
tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentFilePreview.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentImagePreview.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActionFileAttachments.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActionRecord.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatProcessingInfo.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatSettings/ConversationSelectionDialog.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatSettings/ImportExportTab.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatSettings/ParameterSourceIndicator.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
tools/server/webui/src/lib/components/app/dialogs/ChatErrorDialog.svelte [deleted file]
tools/server/webui/src/lib/components/app/dialogs/ConfirmationDialog.svelte [deleted file]
tools/server/webui/src/lib/components/app/dialogs/ConversationTitleUpdateDialog.svelte [deleted file]
tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/EmptyFileAlertDialog.svelte [deleted file]
tools/server/webui/src/lib/components/app/index.ts
tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte [new file with mode: 0644]
tools/server/webui/src/routes/+layout.svelte
tools/server/webui/src/stories/ChatSettings.stories.svelte [new file with mode: 0644]
tools/server/webui/src/stories/ChatSettingsDialog.stories.svelte [deleted file]

index 5ee68eced2a2bc6108b92258c7253e6ac3d02cda..097c9440be2d9bb6cab4a13d359106427ea74ceb 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentFilePreview.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentFilePreview.svelte
deleted file mode 100644 (file)
index 46f0d00..0000000
+++ /dev/null
@@ -1,129 +0,0 @@
-<script lang="ts">
-       import { RemoveButton } from '$lib/components/app';
-       import { formatFileSize, getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
-       import { FileTypeCategory, MimeTypeText } from '$lib/enums/files';
-
-       interface Props {
-               class?: string;
-               id: string;
-               onClick?: (event?: MouseEvent) => void;
-               onRemove?: (id: string) => void;
-               name: string;
-               readonly?: boolean;
-               size?: number;
-               textContent?: string;
-               type: string;
-       }
-
-       let {
-               class: className = '',
-               id,
-               onClick,
-               onRemove,
-               name,
-               readonly = false,
-               size,
-               textContent,
-               type
-       }: Props = $props();
-</script>
-
-{#if type === MimeTypeText.PLAIN || type === FileTypeCategory.TEXT}
-       {#if readonly}
-               <!-- Readonly mode (ChatMessage) -->
-               <button
-                       class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
-                       onclick={onClick}
-                       aria-label={`Preview ${name}`}
-                       type="button"
-               >
-                       <div class="flex items-start gap-3">
-                               <div class="flex min-w-0 flex-1 flex-col items-start text-left">
-                                       <span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
-
-                                       {#if size}
-                                               <span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
-                                       {/if}
-
-                                       {#if textContent && type === 'text'}
-                                               <div class="relative mt-2 w-full">
-                                                       <div
-                                                               class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
-                                                       >
-                                                               {getPreviewText(textContent)}
-                                                       </div>
-
-                                                       {#if textContent.length > 150}
-                                                               <div
-                                                                       class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent"
-                                                               ></div>
-                                                       {/if}
-                                               </div>
-                                       {/if}
-                               </div>
-                       </div>
-               </button>
-       {:else}
-               <!-- Non-readonly mode (ChatForm) -->
-               <button
-                       class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
-                               ? 'max-h-24 max-w-72'
-                               : 'max-w-36'} cursor-pointer text-left"
-                       onclick={onClick}
-               >
-                       <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
-                               <RemoveButton {id} {onRemove} />
-                       </div>
-
-                       <div class="pr-8">
-                               <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
-
-                               {#if textContent}
-                                       <div class="relative">
-                                               <div
-                                                       class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
-                                                       style="max-height: 3rem; line-height: 1.2em;"
-                                               >
-                                                       {getPreviewText(textContent)}
-                                               </div>
-
-                                               {#if textContent.length > 150}
-                                                       <div
-                                                               class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent"
-                                                       ></div>
-                                               {/if}
-                                       </div>
-                               {/if}
-                       </div>
-               </button>
-       {/if}
-{:else}
-       <button
-               class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
-               onclick={onClick}
-       >
-               <div
-                       class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
-               >
-                       {getFileTypeLabel(type)}
-               </div>
-
-               <div class="flex flex-col gap-1">
-                       <span
-                               class="max-w-24 truncate text-sm font-medium text-foreground group-hover:pr-6 md:max-w-32"
-                       >
-                               {name}
-                       </span>
-
-                       {#if size}
-                               <span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
-                       {/if}
-               </div>
-
-               {#if !readonly}
-                       <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
-                               <RemoveButton {id} {onRemove} />
-                       </div>
-               {/if}
-       </button>
-{/if}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentImagePreview.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentImagePreview.svelte
deleted file mode 100644 (file)
index da9ceb6..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-<script lang="ts">
-       import { RemoveButton } from '$lib/components/app';
-
-       interface Props {
-               id: string;
-               name: string;
-               preview: string;
-               readonly?: boolean;
-               onRemove?: (id: string) => void;
-               onClick?: (event?: MouseEvent) => void;
-               class?: string;
-               // Customizable size props
-               width?: string;
-               height?: string;
-               imageClass?: string;
-       }
-
-       let {
-               id,
-               name,
-               preview,
-               readonly = false,
-               onRemove,
-               onClick,
-               class: className = '',
-               // Default to small size for form previews
-               width = 'w-auto',
-               height = 'h-16',
-               imageClass = ''
-       }: Props = $props();
-</script>
-
-<div class="group relative overflow-hidden rounded-lg border border-border bg-muted {className}">
-       {#if onClick}
-               <button
-                       type="button"
-                       class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
-                       onclick={onClick}
-                       aria-label="Preview {name}"
-               >
-                       <img
-                               src={preview}
-                               alt={name}
-                               class="{height} {width} cursor-pointer object-cover {imageClass}"
-                       />
-               </button>
-       {:else}
-               <img
-                       src={preview}
-                       alt={name}
-                       class="{height} {width} cursor-pointer object-cover {imageClass}"
-               />
-       {/if}
-
-       {#if !readonly}
-               <div
-                       class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
-               >
-                       <RemoveButton {id} {onRemove} class="text-white" />
-               </div>
-       {/if}
-</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte
new file mode 100644 (file)
index 0000000..212b1fe
--- /dev/null
@@ -0,0 +1,273 @@
+<script lang="ts">
+       import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
+       import { FileTypeCategory, MimeTypeApplication } from '$lib/enums/files';
+       import { convertPDFToImage } from '$lib/utils/pdf-processing';
+       import { Button } from '$lib/components/ui/button';
+       import { getFileTypeCategory } from '$lib/utils/file-type';
+
+       interface Props {
+               // Either an uploaded file or a stored attachment
+               uploadedFile?: ChatUploadedFile;
+               attachment?: DatabaseMessageExtra;
+               // For uploaded files
+               preview?: string;
+               name?: string;
+               type?: string;
+               textContent?: string;
+       }
+
+       let { uploadedFile, attachment, preview, name, type, textContent }: Props = $props();
+
+       let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
+
+       let displayPreview = $derived(
+               uploadedFile?.preview || (attachment?.type === 'imageFile' ? attachment.base64Url : preview)
+       );
+
+       let displayType = $derived(
+               uploadedFile?.type ||
+                       (attachment?.type === 'imageFile'
+                               ? 'image'
+                               : attachment?.type === 'textFile'
+                                       ? 'text'
+                                       : attachment?.type === 'audioFile'
+                                               ? attachment.mimeType || 'audio'
+                                               : attachment?.type === 'pdfFile'
+                                                       ? MimeTypeApplication.PDF
+                                                       : type || 'unknown')
+       );
+
+       let displayTextContent = $derived(
+               uploadedFile?.textContent ||
+                       (attachment?.type === 'textFile'
+                               ? attachment.content
+                               : attachment?.type === 'pdfFile'
+                                       ? attachment.content
+                                       : textContent)
+       );
+
+       let isAudio = $derived(
+               getFileTypeCategory(displayType) === FileTypeCategory.AUDIO || displayType === 'audio'
+       );
+
+       let isImage = $derived(
+               getFileTypeCategory(displayType) === FileTypeCategory.IMAGE || displayType === 'image'
+       );
+
+       let isPdf = $derived(displayType === MimeTypeApplication.PDF);
+
+       let isText = $derived(
+               getFileTypeCategory(displayType) === FileTypeCategory.TEXT || displayType === 'text'
+       );
+
+       let IconComponent = $derived(() => {
+               if (isImage) return Image;
+               if (isText || isPdf) return FileText;
+               if (isAudio) return Music;
+
+               return FileIcon;
+       });
+
+       let pdfViewMode = $state<'text' | 'pages'>('pages');
+
+       let pdfImages = $state<string[]>([]);
+
+       let pdfImagesLoading = $state(false);
+
+       let pdfImagesError = $state<string | null>(null);
+
+       async function loadPdfImages() {
+               if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
+
+               pdfImagesLoading = true;
+               pdfImagesError = null;
+
+               try {
+                       let file: File | null = null;
+
+                       if (uploadedFile?.file) {
+                               file = uploadedFile.file;
+                       } else if (attachment?.type === 'pdfFile') {
+                               // Check if we have pre-processed images
+                               if (attachment.images && Array.isArray(attachment.images)) {
+                                       pdfImages = attachment.images;
+                                       return;
+                               }
+
+                               // Convert base64 back to File for processing
+                               if (attachment.base64Data) {
+                                       const base64Data = attachment.base64Data;
+                                       const byteCharacters = atob(base64Data);
+                                       const byteNumbers = new Array(byteCharacters.length);
+                                       for (let i = 0; i < byteCharacters.length; i++) {
+                                               byteNumbers[i] = byteCharacters.charCodeAt(i);
+                                       }
+                                       const byteArray = new Uint8Array(byteNumbers);
+                                       file = new File([byteArray], displayName, { type: MimeTypeApplication.PDF });
+                               }
+                       }
+
+                       if (file) {
+                               pdfImages = await convertPDFToImage(file);
+                       } else {
+                               throw new Error('No PDF file available for conversion');
+                       }
+               } catch (error) {
+                       pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
+               } finally {
+                       pdfImagesLoading = false;
+               }
+       }
+
+       export function reset() {
+               pdfImages = [];
+               pdfImagesLoading = false;
+               pdfImagesError = null;
+               pdfViewMode = 'pages';
+       }
+
+       $effect(() => {
+               if (isPdf && pdfViewMode === 'pages') {
+                       loadPdfImages();
+               }
+       });
+</script>
+
+<div class="space-y-4">
+       <div class="flex items-center justify-end gap-6">
+               {#if isPdf}
+                       <div class="flex items-center gap-2">
+                               <Button
+                                       variant={pdfViewMode === 'text' ? 'default' : 'outline'}
+                                       size="sm"
+                                       onclick={() => (pdfViewMode = 'text')}
+                                       disabled={pdfImagesLoading}
+                               >
+                                       <FileText class="mr-1 h-4 w-4" />
+
+                                       Text
+                               </Button>
+
+                               <Button
+                                       variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
+                                       size="sm"
+                                       onclick={() => {
+                                               pdfViewMode = 'pages';
+                                               loadPdfImages();
+                                       }}
+                                       disabled={pdfImagesLoading}
+                               >
+                                       {#if pdfImagesLoading}
+                                               <div
+                                                       class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
+                                               ></div>
+                                       {:else}
+                                               <Eye class="mr-1 h-4 w-4" />
+                                       {/if}
+
+                                       Pages
+                               </Button>
+                       </div>
+               {/if}
+       </div>
+
+       <div class="flex-1 overflow-auto">
+               {#if isImage && displayPreview}
+                       <div class="flex items-center justify-center">
+                               <img
+                                       src={displayPreview}
+                                       alt={displayName}
+                                       class="max-h-full rounded-lg object-contain shadow-lg"
+                               />
+                       </div>
+               {:else if isPdf && pdfViewMode === 'pages'}
+                       {#if pdfImagesLoading}
+                               <div class="flex items-center justify-center p-8">
+                                       <div class="text-center">
+                                               <div
+                                                       class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
+                                               ></div>
+
+                                               <p class="text-muted-foreground">Converting PDF to images...</p>
+                                       </div>
+                               </div>
+                       {:else if pdfImagesError}
+                               <div class="flex items-center justify-center p-8">
+                                       <div class="text-center">
+                                               <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+                                               <p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
+
+                                               <p class="text-sm text-muted-foreground">{pdfImagesError}</p>
+
+                                               <Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
+                                       </div>
+                               </div>
+                       {:else if pdfImages.length > 0}
+                               <div class="max-h-[70vh] space-y-4 overflow-auto">
+                                       {#each pdfImages as image, index (image)}
+                                               <div class="text-center">
+                                                       <p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
+
+                                                       <img
+                                                               src={image}
+                                                               alt="PDF Page {index + 1}"
+                                                               class="mx-auto max-w-full rounded-lg shadow-lg"
+                                                       />
+                                               </div>
+                                       {/each}
+                               </div>
+                       {:else}
+                               <div class="flex items-center justify-center p-8">
+                                       <div class="text-center">
+                                               <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+                                               <p class="mb-4 text-muted-foreground">No PDF pages available</p>
+                                       </div>
+                               </div>
+                       {/if}
+               {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
+                       <div
+                               class="max-h-[60vh] overflow-auto rounded-lg bg-muted p-4 font-mono text-sm break-words whitespace-pre-wrap"
+                       >
+                               {displayTextContent}
+                       </div>
+               {:else if isAudio}
+                       <div class="flex items-center justify-center p-8">
+                               <div class="w-full max-w-md text-center">
+                                       <Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+                                       {#if attachment?.type === 'audioFile'}
+                                               <audio
+                                                       controls
+                                                       class="mb-4 w-full"
+                                                       src="data:{attachment.mimeType};base64,{attachment.base64Data}"
+                                               >
+                                                       Your browser does not support the audio element.
+                                               </audio>
+                                       {:else if uploadedFile?.preview}
+                                               <audio controls class="mb-4 w-full" src={uploadedFile.preview}>
+                                                       Your browser does not support the audio element.
+                                               </audio>
+                                       {:else}
+                                               <p class="mb-4 text-muted-foreground">Audio preview not available</p>
+                                       {/if}
+
+                                       <p class="text-sm text-muted-foreground">
+                                               {displayName}
+                                       </p>
+                               </div>
+                       </div>
+               {:else}
+                       <div class="flex items-center justify-center p-8">
+                               <div class="text-center">
+                                       {#if IconComponent}
+                                               <IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+                                       {/if}
+
+                                       <p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
+                               </div>
+                       </div>
+               {/if}
+       </div>
+</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte
deleted file mode 100644 (file)
index 8a3389b..0000000
+++ /dev/null
@@ -1,314 +0,0 @@
-<script lang="ts">
-       import * as Dialog from '$lib/components/ui/dialog';
-       import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
-       import { FileTypeCategory, MimeTypeApplication } from '$lib/enums/files';
-       import { convertPDFToImage } from '$lib/utils/pdf-processing';
-       import { Button } from '$lib/components/ui/button';
-       import { getFileTypeCategory } from '$lib/utils/file-type';
-       import { formatFileSize } from '$lib/utils/file-preview';
-
-       interface Props {
-               open: boolean;
-               // Either an uploaded file or a stored attachment
-               uploadedFile?: ChatUploadedFile;
-               attachment?: DatabaseMessageExtra;
-               // For uploaded files
-               preview?: string;
-               name?: string;
-               type?: string;
-               size?: number;
-               textContent?: string;
-       }
-
-       let {
-               open = $bindable(),
-               uploadedFile,
-               attachment,
-               preview,
-               name,
-               type,
-               size,
-               textContent
-       }: Props = $props();
-
-       let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
-
-       let displayPreview = $derived(
-               uploadedFile?.preview || (attachment?.type === 'imageFile' ? attachment.base64Url : preview)
-       );
-
-       let displayType = $derived(
-               uploadedFile?.type ||
-                       (attachment?.type === 'imageFile'
-                               ? 'image'
-                               : attachment?.type === 'textFile'
-                                       ? 'text'
-                                       : attachment?.type === 'audioFile'
-                                               ? attachment.mimeType || 'audio'
-                                               : attachment?.type === 'pdfFile'
-                                                       ? MimeTypeApplication.PDF
-                                                       : type || 'unknown')
-       );
-
-       let displaySize = $derived(uploadedFile?.size || size);
-
-       let displayTextContent = $derived(
-               uploadedFile?.textContent ||
-                       (attachment?.type === 'textFile'
-                               ? attachment.content
-                               : attachment?.type === 'pdfFile'
-                                       ? attachment.content
-                                       : textContent)
-       );
-
-       let isAudio = $derived(
-               getFileTypeCategory(displayType) === FileTypeCategory.AUDIO || displayType === 'audio'
-       );
-
-       let isImage = $derived(
-               getFileTypeCategory(displayType) === FileTypeCategory.IMAGE || displayType === 'image'
-       );
-
-       let isPdf = $derived(displayType === MimeTypeApplication.PDF);
-
-       let isText = $derived(
-               getFileTypeCategory(displayType) === FileTypeCategory.TEXT || displayType === 'text'
-       );
-
-       let IconComponent = $derived(() => {
-               if (isImage) return Image;
-               if (isText || isPdf) return FileText;
-               if (isAudio) return Music;
-
-               return FileIcon;
-       });
-
-       let pdfViewMode = $state<'text' | 'pages'>('pages');
-
-       let pdfImages = $state<string[]>([]);
-
-       let pdfImagesLoading = $state(false);
-
-       let pdfImagesError = $state<string | null>(null);
-
-       async function loadPdfImages() {
-               if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
-
-               pdfImagesLoading = true;
-               pdfImagesError = null;
-
-               try {
-                       let file: File | null = null;
-
-                       if (uploadedFile?.file) {
-                               file = uploadedFile.file;
-                       } else if (attachment?.type === 'pdfFile') {
-                               // Check if we have pre-processed images
-                               if (attachment.images && Array.isArray(attachment.images)) {
-                                       pdfImages = attachment.images;
-                                       return;
-                               }
-
-                               // Convert base64 back to File for processing
-                               if (attachment.base64Data) {
-                                       const base64Data = attachment.base64Data;
-                                       const byteCharacters = atob(base64Data);
-                                       const byteNumbers = new Array(byteCharacters.length);
-                                       for (let i = 0; i < byteCharacters.length; i++) {
-                                               byteNumbers[i] = byteCharacters.charCodeAt(i);
-                                       }
-                                       const byteArray = new Uint8Array(byteNumbers);
-                                       file = new File([byteArray], displayName, { type: MimeTypeApplication.PDF });
-                               }
-                       }
-
-                       if (file) {
-                               pdfImages = await convertPDFToImage(file);
-                       } else {
-                               throw new Error('No PDF file available for conversion');
-                       }
-               } catch (error) {
-                       pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
-               } finally {
-                       pdfImagesLoading = false;
-               }
-       }
-
-       $effect(() => {
-               if (open) {
-                       pdfImages = [];
-                       pdfImagesLoading = false;
-                       pdfImagesError = null;
-                       pdfViewMode = 'pages';
-               }
-       });
-
-       $effect(() => {
-               if (open && isPdf && pdfViewMode === 'pages') {
-                       loadPdfImages();
-               }
-       });
-</script>
-
-<Dialog.Root bind:open>
-       <Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden !p-10 sm:w-auto sm:max-w-6xl">
-               <Dialog.Header class="flex-shrink-0">
-                       <div class="flex items-center justify-between gap-6">
-                               <div class="flex items-center gap-3">
-                                       {#if IconComponent}
-                                               <IconComponent class="h-5 w-5 text-muted-foreground" />
-                                       {/if}
-
-                                       <div>
-                                               <Dialog.Title class="text-left">{displayName}</Dialog.Title>
-
-                                               <div class="flex items-center gap-2 text-sm text-muted-foreground">
-                                                       <span>{displayType}</span>
-
-                                                       {#if displaySize}
-                                                               <span>•</span>
-
-                                                               <span>{formatFileSize(displaySize)}</span>
-                                                       {/if}
-                                               </div>
-                                       </div>
-                               </div>
-
-                               {#if isPdf}
-                                       <div class="flex items-center gap-2">
-                                               <Button
-                                                       variant={pdfViewMode === 'text' ? 'default' : 'outline'}
-                                                       size="sm"
-                                                       onclick={() => (pdfViewMode = 'text')}
-                                                       disabled={pdfImagesLoading}
-                                               >
-                                                       <FileText class="mr-1 h-4 w-4" />
-
-                                                       Text
-                                               </Button>
-
-                                               <Button
-                                                       variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
-                                                       size="sm"
-                                                       onclick={() => {
-                                                               pdfViewMode = 'pages';
-                                                               loadPdfImages();
-                                                       }}
-                                                       disabled={pdfImagesLoading}
-                                               >
-                                                       {#if pdfImagesLoading}
-                                                               <div
-                                                                       class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
-                                                               ></div>
-                                                       {:else}
-                                                               <Eye class="mr-1 h-4 w-4" />
-                                                       {/if}
-
-                                                       Pages
-                                               </Button>
-                                       </div>
-                               {/if}
-                       </div>
-               </Dialog.Header>
-
-               <div class="flex-1 overflow-auto">
-                       {#if isImage && displayPreview}
-                               <div class="flex items-center justify-center">
-                                       <img
-                                               src={displayPreview}
-                                               alt={displayName}
-                                               class="max-h-full rounded-lg object-contain shadow-lg"
-                                       />
-                               </div>
-                       {:else if isPdf && pdfViewMode === 'pages'}
-                               {#if pdfImagesLoading}
-                                       <div class="flex items-center justify-center p-8">
-                                               <div class="text-center">
-                                                       <div
-                                                               class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
-                                                       ></div>
-
-                                                       <p class="text-muted-foreground">Converting PDF to images...</p>
-                                               </div>
-                                       </div>
-                               {:else if pdfImagesError}
-                                       <div class="flex items-center justify-center p-8">
-                                               <div class="text-center">
-                                                       <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-
-                                                       <p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
-
-                                                       <p class="text-sm text-muted-foreground">{pdfImagesError}</p>
-
-                                                       <Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
-                                               </div>
-                                       </div>
-                               {:else if pdfImages.length > 0}
-                                       <div class="max-h-[70vh] space-y-4 overflow-auto">
-                                               {#each pdfImages as image, index (image)}
-                                                       <div class="text-center">
-                                                               <p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
-
-                                                               <img
-                                                                       src={image}
-                                                                       alt="PDF Page {index + 1}"
-                                                                       class="mx-auto max-w-full rounded-lg shadow-lg"
-                                                               />
-                                                       </div>
-                                               {/each}
-                                       </div>
-                               {:else}
-                                       <div class="flex items-center justify-center p-8">
-                                               <div class="text-center">
-                                                       <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-
-                                                       <p class="mb-4 text-muted-foreground">No PDF pages available</p>
-                                               </div>
-                                       </div>
-                               {/if}
-                       {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
-                               <div
-                                       class="max-h-[60vh] overflow-auto rounded-lg bg-muted p-4 font-mono text-sm break-words whitespace-pre-wrap"
-                               >
-                                       {displayTextContent}
-                               </div>
-                       {:else if isAudio}
-                               <div class="flex items-center justify-center p-8">
-                                       <div class="w-full max-w-md text-center">
-                                               <Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-
-                                               {#if attachment?.type === 'audioFile'}
-                                                       <audio
-                                                               controls
-                                                               class="mb-4 w-full"
-                                                               src="data:{attachment.mimeType};base64,{attachment.base64Data}"
-                                                       >
-                                                               Your browser does not support the audio element.
-                                                       </audio>
-                                               {:else if uploadedFile?.preview}
-                                                       <audio controls class="mb-4 w-full" src={uploadedFile.preview}>
-                                                               Your browser does not support the audio element.
-                                                       </audio>
-                                               {:else}
-                                                       <p class="mb-4 text-muted-foreground">Audio preview not available</p>
-                                               {/if}
-
-                                               <p class="text-sm text-muted-foreground">
-                                                       {displayName}
-                                               </p>
-                                       </div>
-                               </div>
-                       {:else}
-                               <div class="flex items-center justify-center p-8">
-                                       <div class="text-center">
-                                               {#if IconComponent}
-                                                       <IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-                                               {/if}
-
-                                               <p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
-                                       </div>
-                               </div>
-                       {/if}
-               </div>
-       </Dialog.Content>
-</Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte
new file mode 100644 (file)
index 0000000..46f0d00
--- /dev/null
@@ -0,0 +1,129 @@
+<script lang="ts">
+       import { RemoveButton } from '$lib/components/app';
+       import { formatFileSize, getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
+       import { FileTypeCategory, MimeTypeText } from '$lib/enums/files';
+
+       interface Props {
+               class?: string;
+               id: string;
+               onClick?: (event?: MouseEvent) => void;
+               onRemove?: (id: string) => void;
+               name: string;
+               readonly?: boolean;
+               size?: number;
+               textContent?: string;
+               type: string;
+       }
+
+       let {
+               class: className = '',
+               id,
+               onClick,
+               onRemove,
+               name,
+               readonly = false,
+               size,
+               textContent,
+               type
+       }: Props = $props();
+</script>
+
+{#if type === MimeTypeText.PLAIN || type === FileTypeCategory.TEXT}
+       {#if readonly}
+               <!-- Readonly mode (ChatMessage) -->
+               <button
+                       class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
+                       onclick={onClick}
+                       aria-label={`Preview ${name}`}
+                       type="button"
+               >
+                       <div class="flex items-start gap-3">
+                               <div class="flex min-w-0 flex-1 flex-col items-start text-left">
+                                       <span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
+
+                                       {#if size}
+                                               <span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
+                                       {/if}
+
+                                       {#if textContent && type === 'text'}
+                                               <div class="relative mt-2 w-full">
+                                                       <div
+                                                               class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
+                                                       >
+                                                               {getPreviewText(textContent)}
+                                                       </div>
+
+                                                       {#if textContent.length > 150}
+                                                               <div
+                                                                       class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent"
+                                                               ></div>
+                                                       {/if}
+                                               </div>
+                                       {/if}
+                               </div>
+                       </div>
+               </button>
+       {:else}
+               <!-- Non-readonly mode (ChatForm) -->
+               <button
+                       class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
+                               ? 'max-h-24 max-w-72'
+                               : 'max-w-36'} cursor-pointer text-left"
+                       onclick={onClick}
+               >
+                       <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+                               <RemoveButton {id} {onRemove} />
+                       </div>
+
+                       <div class="pr-8">
+                               <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
+
+                               {#if textContent}
+                                       <div class="relative">
+                                               <div
+                                                       class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
+                                                       style="max-height: 3rem; line-height: 1.2em;"
+                                               >
+                                                       {getPreviewText(textContent)}
+                                               </div>
+
+                                               {#if textContent.length > 150}
+                                                       <div
+                                                               class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent"
+                                                       ></div>
+                                               {/if}
+                                       </div>
+                               {/if}
+                       </div>
+               </button>
+       {/if}
+{:else}
+       <button
+               class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
+               onclick={onClick}
+       >
+               <div
+                       class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
+               >
+                       {getFileTypeLabel(type)}
+               </div>
+
+               <div class="flex flex-col gap-1">
+                       <span
+                               class="max-w-24 truncate text-sm font-medium text-foreground group-hover:pr-6 md:max-w-32"
+                       >
+                               {name}
+                       </span>
+
+                       {#if size}
+                               <span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
+                       {/if}
+               </div>
+
+               {#if !readonly}
+                       <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+                               <RemoveButton {id} {onRemove} />
+                       </div>
+               {/if}
+       </button>
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte
new file mode 100644 (file)
index 0000000..da9ceb6
--- /dev/null
@@ -0,0 +1,62 @@
+<script lang="ts">
+       import { RemoveButton } from '$lib/components/app';
+
+       interface Props {
+               id: string;
+               name: string;
+               preview: string;
+               readonly?: boolean;
+               onRemove?: (id: string) => void;
+               onClick?: (event?: MouseEvent) => void;
+               class?: string;
+               // Customizable size props
+               width?: string;
+               height?: string;
+               imageClass?: string;
+       }
+
+       let {
+               id,
+               name,
+               preview,
+               readonly = false,
+               onRemove,
+               onClick,
+               class: className = '',
+               // Default to small size for form previews
+               width = 'w-auto',
+               height = 'h-16',
+               imageClass = ''
+       }: Props = $props();
+</script>
+
+<div class="group relative overflow-hidden rounded-lg border border-border bg-muted {className}">
+       {#if onClick}
+               <button
+                       type="button"
+                       class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
+                       onclick={onClick}
+                       aria-label="Preview {name}"
+               >
+                       <img
+                               src={preview}
+                               alt={name}
+                               class="{height} {width} cursor-pointer object-cover {imageClass}"
+                       />
+               </button>
+       {:else}
+               <img
+                       src={preview}
+                       alt={name}
+                       class="{height} {width} cursor-pointer object-cover {imageClass}"
+               />
+       {/if}
+
+       {#if !readonly}
+               <div
+                       class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
+               >
+                       <RemoveButton {id} {onRemove} class="text-white" />
+               </div>
+       {/if}
+</div>
index a2aea0232a9f1753062248ec4142847e8b481e5a..050c793316a470c00b7a1597ffa1fd63e301a482 100644 (file)
@@ -1,11 +1,10 @@
 <script lang="ts">
-       import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
+       import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
        import { Button } from '$lib/components/ui/button';
        import { ChevronLeft, ChevronRight } from '@lucide/svelte';
        import { FileTypeCategory } from '$lib/enums/files';
        import { getFileTypeCategory } from '$lib/utils/file-type';
-       import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
-       import ChatAttachmentsViewAllDialog from './ChatAttachmentsViewAllDialog.svelte';
+       import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
        import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
 
        interface Props {
                        >
                                {#each displayItems as item (item.id)}
                                        {#if item.isImage && item.preview}
-                                               <ChatAttachmentImagePreview
+                                               <ChatAttachmentThumbnailImage
                                                        class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
                                                        id={item.id}
                                                        name={item.name}
                                                        onClick={(event) => openPreview(item, event)}
                                                />
                                        {:else}
-                                               <ChatAttachmentFilePreview
+                                               <ChatAttachmentThumbnailFile
                                                        class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
                                                        id={item.id}
                                                        name={item.name}
 {/if}
 
 {#if previewItem}
-       <ChatAttachmentPreviewDialog
+       <DialogChatAttachmentPreview
                bind:open={previewDialogOpen}
                uploadedFile={previewItem.uploadedFile}
                attachment={previewItem.attachment}
        />
 {/if}
 
-<ChatAttachmentsViewAllDialog
+<DialogChatAttachmentsViewAll
        bind:open={viewAllDialogOpen}
        {uploadedFiles}
        {attachments}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte
new file mode 100644 (file)
index 0000000..ae82f7b
--- /dev/null
@@ -0,0 +1,190 @@
+<script lang="ts">
+       import {
+               ChatAttachmentThumbnailImage,
+               ChatAttachmentThumbnailFile,
+               DialogChatAttachmentPreview
+       } from '$lib/components/app';
+       import { FileTypeCategory } from '$lib/enums/files';
+       import { getFileTypeCategory } from '$lib/utils/file-type';
+       import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
+
+       interface Props {
+               uploadedFiles?: ChatUploadedFile[];
+               attachments?: DatabaseMessageExtra[];
+               readonly?: boolean;
+               onFileRemove?: (fileId: string) => void;
+               imageHeight?: string;
+               imageWidth?: string;
+               imageClass?: string;
+       }
+
+       let {
+               uploadedFiles = [],
+               attachments = [],
+               readonly = false,
+               onFileRemove,
+               imageHeight = 'h-24',
+               imageWidth = 'w-auto',
+               imageClass = ''
+       }: Props = $props();
+
+       let previewDialogOpen = $state(false);
+       let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+
+       let displayItems = $derived(getDisplayItems());
+       let imageItems = $derived(displayItems.filter((item) => item.isImage));
+       let fileItems = $derived(displayItems.filter((item) => !item.isImage));
+
+       function getDisplayItems(): ChatAttachmentDisplayItem[] {
+               const items: ChatAttachmentDisplayItem[] = [];
+
+               for (const file of uploadedFiles) {
+                       items.push({
+                               id: file.id,
+                               name: file.name,
+                               size: file.size,
+                               preview: file.preview,
+                               type: file.type,
+                               isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
+                               uploadedFile: file,
+                               textContent: file.textContent
+                       });
+               }
+
+               for (const [index, attachment] of attachments.entries()) {
+                       if (attachment.type === 'imageFile') {
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       preview: attachment.base64Url,
+                                       type: 'image',
+                                       isImage: true,
+                                       attachment,
+                                       attachmentIndex: index
+                               });
+                       } else if (attachment.type === 'textFile') {
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       type: 'text',
+                                       isImage: false,
+                                       attachment,
+                                       attachmentIndex: index,
+                                       textContent: attachment.content
+                               });
+                       } else if (attachment.type === 'context') {
+                               // Legacy format from old webui - treat as text file
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       type: 'text',
+                                       isImage: false,
+                                       attachment,
+                                       attachmentIndex: index,
+                                       textContent: attachment.content
+                               });
+                       } else if (attachment.type === 'audioFile') {
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       type: attachment.mimeType || 'audio',
+                                       isImage: false,
+                                       attachment,
+                                       attachmentIndex: index
+                               });
+                       } else if (attachment.type === 'pdfFile') {
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       type: 'application/pdf',
+                                       isImage: false,
+                                       attachment,
+                                       attachmentIndex: index,
+                                       textContent: attachment.content
+                               });
+                       }
+               }
+
+               return items.reverse();
+       }
+
+       function openPreview(item: (typeof displayItems)[0], event?: Event) {
+               if (event) {
+                       event.preventDefault();
+                       event.stopPropagation();
+               }
+
+               previewItem = {
+                       uploadedFile: item.uploadedFile,
+                       attachment: item.attachment,
+                       preview: item.preview,
+                       name: item.name,
+                       type: item.type,
+                       size: item.size,
+                       textContent: item.textContent
+               };
+               previewDialogOpen = true;
+       }
+</script>
+
+<div class="space-y-4">
+       <div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
+               {#if fileItems.length > 0}
+                       <div>
+                               <h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
+                               <div class="flex flex-wrap items-start gap-3">
+                                       {#each fileItems as item (item.id)}
+                                               <ChatAttachmentThumbnailFile
+                                                       class="cursor-pointer"
+                                                       id={item.id}
+                                                       name={item.name}
+                                                       type={item.type}
+                                                       size={item.size}
+                                                       {readonly}
+                                                       onRemove={onFileRemove}
+                                                       textContent={item.textContent}
+                                                       onClick={(event) => openPreview(item, event)}
+                                               />
+                                       {/each}
+                               </div>
+                       </div>
+               {/if}
+
+               {#if imageItems.length > 0}
+                       <div>
+                               <h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
+                               <div class="flex flex-wrap items-start gap-3">
+                                       {#each imageItems as item (item.id)}
+                                               {#if item.preview}
+                                                       <ChatAttachmentThumbnailImage
+                                                               class="cursor-pointer"
+                                                               id={item.id}
+                                                               name={item.name}
+                                                               preview={item.preview}
+                                                               {readonly}
+                                                               onRemove={onFileRemove}
+                                                               height={imageHeight}
+                                                               width={imageWidth}
+                                                               {imageClass}
+                                                               onClick={(event) => openPreview(item, event)}
+                                                       />
+                                               {/if}
+                                       {/each}
+                               </div>
+                       </div>
+               {/if}
+       </div>
+</div>
+
+{#if previewItem}
+       <DialogChatAttachmentPreview
+               bind:open={previewDialogOpen}
+               uploadedFile={previewItem.uploadedFile}
+               attachment={previewItem.attachment}
+               preview={previewItem.preview}
+               name={previewItem.name}
+               type={previewItem.type}
+               size={previewItem.size}
+               textContent={previewItem.textContent}
+       />
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte
deleted file mode 100644 (file)
index a56e265..0000000
+++ /dev/null
@@ -1,203 +0,0 @@
-<script lang="ts">
-       import * as Dialog from '$lib/components/ui/dialog';
-       import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
-       import { FileTypeCategory } from '$lib/enums/files';
-       import { getFileTypeCategory } from '$lib/utils/file-type';
-       import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
-       import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
-
-       interface Props {
-               open?: boolean;
-               uploadedFiles?: ChatUploadedFile[];
-               attachments?: DatabaseMessageExtra[];
-               readonly?: boolean;
-               onFileRemove?: (fileId: string) => void;
-               imageHeight?: string;
-               imageWidth?: string;
-               imageClass?: string;
-       }
-
-       let {
-               open = $bindable(false),
-               uploadedFiles = [],
-               attachments = [],
-               readonly = false,
-               onFileRemove,
-               imageHeight = 'h-24',
-               imageWidth = 'w-auto',
-               imageClass = ''
-       }: Props = $props();
-
-       let previewDialogOpen = $state(false);
-       let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
-
-       let displayItems = $derived(getDisplayItems());
-       let imageItems = $derived(displayItems.filter((item) => item.isImage));
-       let fileItems = $derived(displayItems.filter((item) => !item.isImage));
-
-       function getDisplayItems(): ChatAttachmentDisplayItem[] {
-               const items: ChatAttachmentDisplayItem[] = [];
-
-               for (const file of uploadedFiles) {
-                       items.push({
-                               id: file.id,
-                               name: file.name,
-                               size: file.size,
-                               preview: file.preview,
-                               type: file.type,
-                               isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
-                               uploadedFile: file,
-                               textContent: file.textContent
-                       });
-               }
-
-               for (const [index, attachment] of attachments.entries()) {
-                       if (attachment.type === 'imageFile') {
-                               items.push({
-                                       id: `attachment-${index}`,
-                                       name: attachment.name,
-                                       preview: attachment.base64Url,
-                                       type: 'image',
-                                       isImage: true,
-                                       attachment,
-                                       attachmentIndex: index
-                               });
-                       } else if (attachment.type === 'textFile') {
-                               items.push({
-                                       id: `attachment-${index}`,
-                                       name: attachment.name,
-                                       type: 'text',
-                                       isImage: false,
-                                       attachment,
-                                       attachmentIndex: index,
-                                       textContent: attachment.content
-                               });
-                       } else if (attachment.type === 'context') {
-                               // Legacy format from old webui - treat as text file
-                               items.push({
-                                       id: `attachment-${index}`,
-                                       name: attachment.name,
-                                       type: 'text',
-                                       isImage: false,
-                                       attachment,
-                                       attachmentIndex: index,
-                                       textContent: attachment.content
-                               });
-                       } else if (attachment.type === 'audioFile') {
-                               items.push({
-                                       id: `attachment-${index}`,
-                                       name: attachment.name,
-                                       type: attachment.mimeType || 'audio',
-                                       isImage: false,
-                                       attachment,
-                                       attachmentIndex: index
-                               });
-                       } else if (attachment.type === 'pdfFile') {
-                               items.push({
-                                       id: `attachment-${index}`,
-                                       name: attachment.name,
-                                       type: 'application/pdf',
-                                       isImage: false,
-                                       attachment,
-                                       attachmentIndex: index,
-                                       textContent: attachment.content
-                               });
-                       }
-               }
-
-               return items.reverse();
-       }
-
-       function openPreview(item: (typeof displayItems)[0], event?: Event) {
-               if (event) {
-                       event.preventDefault();
-                       event.stopPropagation();
-               }
-
-               previewItem = {
-                       uploadedFile: item.uploadedFile,
-                       attachment: item.attachment,
-                       preview: item.preview,
-                       name: item.name,
-                       type: item.type,
-                       size: item.size,
-                       textContent: item.textContent
-               };
-               previewDialogOpen = true;
-       }
-</script>
-
-<Dialog.Root bind:open>
-       <Dialog.Portal>
-               <Dialog.Overlay />
-
-               <Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
-                       <Dialog.Header>
-                               <Dialog.Title>All Attachments ({displayItems.length})</Dialog.Title>
-                               <Dialog.Description class="text-sm text-muted-foreground">
-                                       View and manage all attached files
-                               </Dialog.Description>
-                       </Dialog.Header>
-
-                       <div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
-                               {#if fileItems.length > 0}
-                                       <div>
-                                               <h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
-                                               <div class="flex flex-wrap items-start gap-3">
-                                                       {#each fileItems as item (item.id)}
-                                                               <ChatAttachmentFilePreview
-                                                                       class="cursor-pointer"
-                                                                       id={item.id}
-                                                                       name={item.name}
-                                                                       type={item.type}
-                                                                       size={item.size}
-                                                                       {readonly}
-                                                                       onRemove={onFileRemove}
-                                                                       textContent={item.textContent}
-                                                                       onClick={(event) => openPreview(item, event)}
-                                                               />
-                                                       {/each}
-                                               </div>
-                                       </div>
-                               {/if}
-
-                               {#if imageItems.length > 0}
-                                       <div>
-                                               <h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
-                                               <div class="flex flex-wrap items-start gap-3">
-                                                       {#each imageItems as item (item.id)}
-                                                               {#if item.preview}
-                                                                       <ChatAttachmentImagePreview
-                                                                               class="cursor-pointer"
-                                                                               id={item.id}
-                                                                               name={item.name}
-                                                                               preview={item.preview}
-                                                                               {readonly}
-                                                                               onRemove={onFileRemove}
-                                                                               height={imageHeight}
-                                                                               width={imageWidth}
-                                                                               {imageClass}
-                                                                               onClick={(event) => openPreview(item, event)}
-                                                                       />
-                                                               {/if}
-                                                       {/each}
-                                               </div>
-                                       </div>
-                               {/if}
-                       </div>
-               </Dialog.Content>
-       </Dialog.Portal>
-</Dialog.Root>
-
-{#if previewItem}
-       <ChatAttachmentPreviewDialog
-               bind:open={previewDialogOpen}
-               uploadedFile={previewItem.uploadedFile}
-               attachment={previewItem.attachment}
-               preview={previewItem.preview}
-               name={previewItem.name}
-               type={previewItem.type}
-               size={previewItem.size}
-               textContent={previewItem.textContent}
-       />
-{/if}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActionFileAttachments.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActionFileAttachments.svelte
deleted file mode 100644 (file)
index 71cb88e..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-<script lang="ts">
-       import { Paperclip, Image, FileText, File, Volume2 } 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 { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
-       import { FileTypeCategory } from '$lib/enums/files';
-       import { supportsAudio, supportsVision } from '$lib/stores/server.svelte';
-
-       interface Props {
-               class?: string;
-               disabled?: boolean;
-               onFileUpload?: (fileType?: FileTypeCategory) => void;
-       }
-
-       let { class: className = '', disabled = false, onFileUpload }: Props = $props();
-
-       const fileUploadTooltipText = $derived.by(() => {
-               return !supportsVision()
-                       ? 'Text files and PDFs supported. Images, audio, and video require vision models.'
-                       : 'Attach files';
-       });
-
-       function handleFileUpload(fileType?: FileTypeCategory) {
-               onFileUpload?.(fileType);
-       }
-</script>
-
-<div class="flex items-center gap-1 {className}">
-       <DropdownMenu.Root>
-               <DropdownMenu.Trigger name="Attach files">
-                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
-                               <Tooltip.Trigger>
-                                       <Button
-                                               class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
-                                               {disabled}
-                                               type="button"
-                                       >
-                                               <span class="sr-only">Attach files</span>
-
-                                               <Paperclip class="h-4 w-4" />
-                                       </Button>
-                               </Tooltip.Trigger>
-
-                               <Tooltip.Content>
-                                       <p>{fileUploadTooltipText}</p>
-                               </Tooltip.Content>
-                       </Tooltip.Root>
-               </DropdownMenu.Trigger>
-
-               <DropdownMenu.Content align="start" class="w-48">
-                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
-                               <Tooltip.Trigger class="w-full">
-                                       <DropdownMenu.Item
-                                               class="images-button flex cursor-pointer items-center gap-2"
-                                               disabled={!supportsVision()}
-                                               onclick={() => handleFileUpload(FileTypeCategory.IMAGE)}
-                                       >
-                                               <Image class="h-4 w-4" />
-
-                                               <span>Images</span>
-                                       </DropdownMenu.Item>
-                               </Tooltip.Trigger>
-
-                               {#if !supportsVision()}
-                                       <Tooltip.Content>
-                                               <p>Images require vision models to be processed</p>
-                                       </Tooltip.Content>
-                               {/if}
-                       </Tooltip.Root>
-
-                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
-                               <Tooltip.Trigger class="w-full">
-                                       <DropdownMenu.Item
-                                               class="audio-button flex cursor-pointer items-center gap-2"
-                                               disabled={!supportsAudio()}
-                                               onclick={() => handleFileUpload(FileTypeCategory.AUDIO)}
-                                       >
-                                               <Volume2 class="h-4 w-4" />
-
-                                               <span>Audio Files</span>
-                                       </DropdownMenu.Item>
-                               </Tooltip.Trigger>
-
-                               {#if !supportsAudio()}
-                                       <Tooltip.Content>
-                                               <p>Audio files require audio models to be processed</p>
-                                       </Tooltip.Content>
-                               {/if}
-                       </Tooltip.Root>
-
-                       <DropdownMenu.Item
-                               class="flex cursor-pointer items-center gap-2"
-                               onclick={() => handleFileUpload(FileTypeCategory.TEXT)}
-                       >
-                               <FileText class="h-4 w-4" />
-
-                               <span>Text Files</span>
-                       </DropdownMenu.Item>
-
-                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
-                               <Tooltip.Trigger class="w-full">
-                                       <DropdownMenu.Item
-                                               class="flex cursor-pointer items-center gap-2"
-                                               onclick={() => handleFileUpload(FileTypeCategory.PDF)}
-                                       >
-                                               <File class="h-4 w-4" />
-
-                                               <span>PDF Files</span>
-                                       </DropdownMenu.Item>
-                               </Tooltip.Trigger>
-
-                               {#if !supportsVision()}
-                                       <Tooltip.Content>
-                                               <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
-                                       </Tooltip.Content>
-                               {/if}
-                       </Tooltip.Root>
-               </DropdownMenu.Content>
-       </DropdownMenu.Root>
-</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActionRecord.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActionRecord.svelte
deleted file mode 100644 (file)
index d08a697..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-<script lang="ts">
-       import { Mic } from '@lucide/svelte';
-       import { Button } from '$lib/components/ui/button';
-       import * as Tooltip from '$lib/components/ui/tooltip';
-       import { supportsAudio } from '$lib/stores/server.svelte';
-
-       interface Props {
-               class?: string;
-               disabled?: boolean;
-               isLoading?: boolean;
-               isRecording?: boolean;
-               onMicClick?: () => void;
-       }
-
-       let {
-               class: className = '',
-               disabled = false,
-               isLoading = false,
-               isRecording = false,
-               onMicClick
-       }: Props = $props();
-</script>
-
-<div class="flex items-center gap-1 {className}">
-       <Tooltip.Root delayDuration={100}>
-               <Tooltip.Trigger>
-                       <Button
-                               class="h-8 w-8 rounded-full p-0 {isRecording
-                                       ? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
-                                       : 'bg-transparent text-muted-foreground hover:bg-foreground/10 hover:text-foreground'} {!supportsAudio()
-                                       ? 'cursor-not-allowed opacity-50'
-                                       : ''}"
-                               disabled={disabled || isLoading || !supportsAudio()}
-                               onclick={onMicClick}
-                               type="button"
-                       >
-                               <span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
-
-                               <Mic class="h-4 w-4" />
-                       </Button>
-               </Tooltip.Trigger>
-
-               {#if !supportsAudio()}
-                       <Tooltip.Content>
-                               <p>Current model does not support audio</p>
-                       </Tooltip.Content>
-               {/if}
-       </Tooltip.Root>
-</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions.svelte
deleted file mode 100644 (file)
index ef03f73..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-<script lang="ts">
-       import { Square, ArrowUp } from '@lucide/svelte';
-       import { Button } from '$lib/components/ui/button';
-       import ChatFormActionFileAttachments from './ChatFormActionFileAttachments.svelte';
-       import ChatFormActionRecord from './ChatFormActionRecord.svelte';
-       import ChatFormModelSelector from './ChatFormModelSelector.svelte';
-       import { config } from '$lib/stores/settings.svelte';
-       import type { FileTypeCategory } from '$lib/enums/files';
-
-       interface Props {
-               canSend?: boolean;
-               class?: string;
-               disabled?: boolean;
-               isLoading?: boolean;
-               isRecording?: boolean;
-               onFileUpload?: (fileType?: FileTypeCategory) => void;
-               onMicClick?: () => void;
-               onStop?: () => void;
-       }
-
-       let {
-               canSend = false,
-               class: className = '',
-               disabled = false,
-               isLoading = false,
-               isRecording = false,
-               onFileUpload,
-               onMicClick,
-               onStop
-       }: Props = $props();
-
-       let currentConfig = $derived(config());
-</script>
-
-<div class="flex w-full items-center gap-2 {className}">
-       <ChatFormActionFileAttachments class="mr-auto" {disabled} {onFileUpload} />
-
-       {#if currentConfig.modelSelectorEnabled}
-               <ChatFormModelSelector class="shrink-0" />
-       {/if}
-
-       {#if isLoading}
-               <Button
-                       type="button"
-                       onclick={onStop}
-                       class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
-               >
-                       <span class="sr-only">Stop</span>
-                       <Square class="h-8 w-8 fill-destructive stroke-destructive" />
-               </Button>
-       {:else}
-               <ChatFormActionRecord {disabled} {isLoading} {isRecording} {onMicClick} />
-
-               <Button
-                       type="submit"
-                       disabled={!canSend || disabled || isLoading}
-                       class="h-8 w-8 rounded-full p-0"
-               >
-                       <span class="sr-only">Send</span>
-                       <ArrowUp class="h-12 w-12" />
-               </Button>
-       {/if}
-</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte
new file mode 100644 (file)
index 0000000..71cb88e
--- /dev/null
@@ -0,0 +1,121 @@
+<script lang="ts">
+       import { Paperclip, Image, FileText, File, Volume2 } 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 { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
+       import { FileTypeCategory } from '$lib/enums/files';
+       import { supportsAudio, supportsVision } from '$lib/stores/server.svelte';
+
+       interface Props {
+               class?: string;
+               disabled?: boolean;
+               onFileUpload?: (fileType?: FileTypeCategory) => void;
+       }
+
+       let { class: className = '', disabled = false, onFileUpload }: Props = $props();
+
+       const fileUploadTooltipText = $derived.by(() => {
+               return !supportsVision()
+                       ? 'Text files and PDFs supported. Images, audio, and video require vision models.'
+                       : 'Attach files';
+       });
+
+       function handleFileUpload(fileType?: FileTypeCategory) {
+               onFileUpload?.(fileType);
+       }
+</script>
+
+<div class="flex items-center gap-1 {className}">
+       <DropdownMenu.Root>
+               <DropdownMenu.Trigger name="Attach files">
+                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+                               <Tooltip.Trigger>
+                                       <Button
+                                               class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
+                                               {disabled}
+                                               type="button"
+                                       >
+                                               <span class="sr-only">Attach files</span>
+
+                                               <Paperclip class="h-4 w-4" />
+                                       </Button>
+                               </Tooltip.Trigger>
+
+                               <Tooltip.Content>
+                                       <p>{fileUploadTooltipText}</p>
+                               </Tooltip.Content>
+                       </Tooltip.Root>
+               </DropdownMenu.Trigger>
+
+               <DropdownMenu.Content align="start" class="w-48">
+                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+                               <Tooltip.Trigger class="w-full">
+                                       <DropdownMenu.Item
+                                               class="images-button flex cursor-pointer items-center gap-2"
+                                               disabled={!supportsVision()}
+                                               onclick={() => handleFileUpload(FileTypeCategory.IMAGE)}
+                                       >
+                                               <Image class="h-4 w-4" />
+
+                                               <span>Images</span>
+                                       </DropdownMenu.Item>
+                               </Tooltip.Trigger>
+
+                               {#if !supportsVision()}
+                                       <Tooltip.Content>
+                                               <p>Images require vision models to be processed</p>
+                                       </Tooltip.Content>
+                               {/if}
+                       </Tooltip.Root>
+
+                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+                               <Tooltip.Trigger class="w-full">
+                                       <DropdownMenu.Item
+                                               class="audio-button flex cursor-pointer items-center gap-2"
+                                               disabled={!supportsAudio()}
+                                               onclick={() => handleFileUpload(FileTypeCategory.AUDIO)}
+                                       >
+                                               <Volume2 class="h-4 w-4" />
+
+                                               <span>Audio Files</span>
+                                       </DropdownMenu.Item>
+                               </Tooltip.Trigger>
+
+                               {#if !supportsAudio()}
+                                       <Tooltip.Content>
+                                               <p>Audio files require audio models to be processed</p>
+                                       </Tooltip.Content>
+                               {/if}
+                       </Tooltip.Root>
+
+                       <DropdownMenu.Item
+                               class="flex cursor-pointer items-center gap-2"
+                               onclick={() => handleFileUpload(FileTypeCategory.TEXT)}
+                       >
+                               <FileText class="h-4 w-4" />
+
+                               <span>Text Files</span>
+                       </DropdownMenu.Item>
+
+                       <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+                               <Tooltip.Trigger class="w-full">
+                                       <DropdownMenu.Item
+                                               class="flex cursor-pointer items-center gap-2"
+                                               onclick={() => handleFileUpload(FileTypeCategory.PDF)}
+                                       >
+                                               <File class="h-4 w-4" />
+
+                                               <span>PDF Files</span>
+                                       </DropdownMenu.Item>
+                               </Tooltip.Trigger>
+
+                               {#if !supportsVision()}
+                                       <Tooltip.Content>
+                                               <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
+                                       </Tooltip.Content>
+                               {/if}
+                       </Tooltip.Root>
+               </DropdownMenu.Content>
+       </DropdownMenu.Root>
+</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
new file mode 100644 (file)
index 0000000..d08a697
--- /dev/null
@@ -0,0 +1,49 @@
+<script lang="ts">
+       import { Mic } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+       import { supportsAudio } from '$lib/stores/server.svelte';
+
+       interface Props {
+               class?: string;
+               disabled?: boolean;
+               isLoading?: boolean;
+               isRecording?: boolean;
+               onMicClick?: () => void;
+       }
+
+       let {
+               class: className = '',
+               disabled = false,
+               isLoading = false,
+               isRecording = false,
+               onMicClick
+       }: Props = $props();
+</script>
+
+<div class="flex items-center gap-1 {className}">
+       <Tooltip.Root delayDuration={100}>
+               <Tooltip.Trigger>
+                       <Button
+                               class="h-8 w-8 rounded-full p-0 {isRecording
+                                       ? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
+                                       : 'bg-transparent text-muted-foreground hover:bg-foreground/10 hover:text-foreground'} {!supportsAudio()
+                                       ? 'cursor-not-allowed opacity-50'
+                                       : ''}"
+                               disabled={disabled || isLoading || !supportsAudio()}
+                               onclick={onMicClick}
+                               type="button"
+                       >
+                               <span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
+
+                               <Mic class="h-4 w-4" />
+                       </Button>
+               </Tooltip.Trigger>
+
+               {#if !supportsAudio()}
+                       <Tooltip.Content>
+                               <p>Current model does not support audio</p>
+                       </Tooltip.Content>
+               {/if}
+       </Tooltip.Root>
+</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
new file mode 100644 (file)
index 0000000..aa50042
--- /dev/null
@@ -0,0 +1,65 @@
+<script lang="ts">
+       import { Square, ArrowUp } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import {
+               ChatFormActionFileAttachments,
+               ChatFormActionRecord,
+               ChatFormModelSelector
+       } from '$lib/components/app';
+       import { config } from '$lib/stores/settings.svelte';
+       import type { FileTypeCategory } from '$lib/enums/files';
+
+       interface Props {
+               canSend?: boolean;
+               class?: string;
+               disabled?: boolean;
+               isLoading?: boolean;
+               isRecording?: boolean;
+               onFileUpload?: (fileType?: FileTypeCategory) => void;
+               onMicClick?: () => void;
+               onStop?: () => void;
+       }
+
+       let {
+               canSend = false,
+               class: className = '',
+               disabled = false,
+               isLoading = false,
+               isRecording = false,
+               onFileUpload,
+               onMicClick,
+               onStop
+       }: Props = $props();
+
+       let currentConfig = $derived(config());
+</script>
+
+<div class="flex w-full items-center gap-2 {className}">
+       <ChatFormActionFileAttachments class="mr-auto" {disabled} {onFileUpload} />
+
+       {#if currentConfig.modelSelectorEnabled}
+               <ChatFormModelSelector class="shrink-0" />
+       {/if}
+
+       {#if isLoading}
+               <Button
+                       type="button"
+                       onclick={onStop}
+                       class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
+               >
+                       <span class="sr-only">Stop</span>
+                       <Square class="h-8 w-8 fill-destructive stroke-destructive" />
+               </Button>
+       {:else}
+               <ChatFormActionRecord {disabled} {isLoading} {isRecording} {onMicClick} />
+
+               <Button
+                       type="submit"
+                       disabled={!canSend || disabled || isLoading}
+                       class="h-8 w-8 rounded-full p-0"
+               >
+                       <span class="sr-only">Send</span>
+                       <ArrowUp class="h-12 w-12" />
+               </Button>
+       {/if}
+</div>
index d37d80651406c7da55504d8d2c6231852c96461c..ff335c328cd588b88eb66ca34b371aab0f9f5151 100644 (file)
@@ -1,7 +1,10 @@
 <script lang="ts">
        import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
-       import { ActionButton, ConfirmationDialog } from '$lib/components/app';
-       import ChatMessageBranchingControls from './ChatMessageBranchingControls.svelte';
+       import {
+               ActionButton,
+               ChatMessageBranchingControls,
+               DialogConfirmation
+       } from '$lib/components/app';
 
        interface Props {
                role: 'user' | 'assistant';
@@ -80,7 +83,7 @@
        </div>
 </div>
 
-<ConfirmationDialog
+<DialogConfirmation
        bind:open={showDeleteDialog}
        title="Delete Message"
        description={deletionInfo && deletionInfo.totalCount > 1
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatProcessingInfo.svelte b/tools/server/webui/src/lib/components/app/chat/ChatProcessingInfo.svelte
deleted file mode 100644 (file)
index ecab232..0000000
+++ /dev/null
@@ -1,136 +0,0 @@
-<script lang="ts">
-       import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
-       import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
-       import { slotsService } from '$lib/services/slots';
-       import { isLoading, activeMessages, activeConversation } from '$lib/stores/chat.svelte';
-       import { config } from '$lib/stores/settings.svelte';
-
-       const processingState = useProcessingState();
-
-       let isCurrentConversationLoading = $derived(isLoading());
-       let processingDetails = $derived(processingState.getProcessingDetails());
-       let showSlotsInfo = $derived(isCurrentConversationLoading || config().keepStatsVisible);
-
-       // Track loading state reactively by checking if conversation ID is in loading conversations array
-       $effect(() => {
-               const keepStatsVisible = config().keepStatsVisible;
-
-               if (keepStatsVisible || isCurrentConversationLoading) {
-                       processingState.startMonitoring();
-               }
-
-               if (!isCurrentConversationLoading && !keepStatsVisible) {
-                       setTimeout(() => {
-                               if (!config().keepStatsVisible) {
-                                       processingState.stopMonitoring();
-                               }
-                       }, PROCESSING_INFO_TIMEOUT);
-               }
-       });
-
-       // Update processing state from stored timings
-       $effect(() => {
-               const conversation = activeConversation();
-               const messages = activeMessages() as DatabaseMessage[];
-               const keepStatsVisible = config().keepStatsVisible;
-
-               if (keepStatsVisible && conversation) {
-                       if (messages.length === 0) {
-                               slotsService.clearConversationState(conversation.id);
-                               return;
-                       }
-
-                       // Search backwards through messages to find most recent assistant message with timing data
-                       // Using reverse iteration for performance - avoids array copy and stops at first match
-                       let foundTimingData = false;
-
-                       for (let i = messages.length - 1; i >= 0; i--) {
-                               const message = messages[i];
-                               if (message.role === 'assistant' && message.timings) {
-                                       foundTimingData = true;
-
-                                       slotsService
-                                               .updateFromTimingData(
-                                                       {
-                                                               prompt_n: message.timings.prompt_n || 0,
-                                                               predicted_n: message.timings.predicted_n || 0,
-                                                               predicted_per_second:
-                                                                       message.timings.predicted_n && message.timings.predicted_ms
-                                                                               ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
-                                                                               : 0,
-                                                               cache_n: message.timings.cache_n || 0
-                                                       },
-                                                       conversation.id
-                                               )
-                                               .catch((error) => {
-                                                       console.warn('Failed to update processing state from stored timings:', error);
-                                               });
-                                       break;
-                               }
-                       }
-
-                       if (!foundTimingData) {
-                               slotsService.clearConversationState(conversation.id);
-                       }
-               }
-       });
-</script>
-
-<div class="chat-processing-info-container pointer-events-none" class:visible={showSlotsInfo}>
-       <div class="chat-processing-info-content">
-               {#each processingDetails as detail (detail)}
-                       <span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
-               {/each}
-       </div>
-</div>
-
-<style>
-       .chat-processing-info-container {
-               position: sticky;
-               top: 0;
-               z-index: 10;
-               padding: 1.5rem 1rem;
-               opacity: 0;
-               transform: translateY(50%);
-               transition:
-                       opacity 300ms ease-out,
-                       transform 300ms ease-out;
-       }
-
-       .chat-processing-info-container.visible {
-               opacity: 1;
-               transform: translateY(0);
-       }
-
-       .chat-processing-info-content {
-               display: flex;
-               flex-wrap: wrap;
-               align-items: center;
-               gap: 1rem;
-               justify-content: center;
-               max-width: 48rem;
-               margin: 0 auto;
-       }
-
-       .chat-processing-info-detail {
-               color: var(--muted-foreground);
-               font-size: 0.75rem;
-               padding: 0.25rem 0.75rem;
-               background: var(--muted);
-               border-radius: 0.375rem;
-               font-family:
-                       ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
-               white-space: nowrap;
-       }
-
-       @media (max-width: 768px) {
-               .chat-processing-info-content {
-                       gap: 0.5rem;
-               }
-
-               .chat-processing-info-detail {
-                       font-size: 0.7rem;
-                       padding: 0.2rem 0.5rem;
-               }
-       }
-</style>
index 0c754faa8823372591a630b44402ef38c5823e7a..e891f7efdc55ff8e296ad5d36b27b3a7a94a7882 100644 (file)
@@ -5,13 +5,13 @@
                ChatScreenHeader,
                ChatScreenWarning,
                ChatMessages,
-               ChatProcessingInfo,
-               EmptyFileAlertDialog,
-               ChatErrorDialog,
+               ChatScreenProcessingInfo,
+               DialogEmptyFileAlert,
+               DialogChatError,
                ServerErrorSplash,
                ServerInfo,
                ServerLoadingSplash,
-               ConfirmationDialog
+               DialogConfirmation
        } from '$lib/components/app';
        import * as AlertDialog from '$lib/components/ui/alert-dialog';
        import {
                        class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
                        in:slide={{ duration: 150, axis: 'y' }}
                >
-                       <ChatProcessingInfo />
+                       <ChatScreenProcessingInfo />
 
                        {#if serverWarning()}
                                <ChatScreenWarning class="pointer-events-auto mx-auto max-w-[48rem] px-4" />
        </AlertDialog.Portal>
 </AlertDialog.Root>
 
-<ConfirmationDialog
+<DialogConfirmation
        bind:open={showDeleteDialog}
        title="Delete Conversation"
        description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
        onCancel={() => (showDeleteDialog = false)}
 />
 
-<EmptyFileAlertDialog
+<DialogEmptyFileAlert
        bind:open={showEmptyFileDialog}
        emptyFiles={emptyFileNames}
        onOpenChange={(open) => {
        }}
 />
 
-<ChatErrorDialog
+<DialogChatError
        message={activeErrorDialog?.message ?? ''}
        onOpenChange={handleErrorDialogOpenChange}
        open={Boolean(activeErrorDialog)}
index 4916bde597d27b244a0ab13be60bd5151201c564..24803d0a00b9ff3b9a2150730932a01e3b071038 100644 (file)
@@ -1,6 +1,6 @@
 <script lang="ts">
        import { Settings } from '@lucide/svelte';
-       import { ChatSettingsDialog } from '$lib/components/app';
+       import { DialogChatSettings } from '$lib/components/app';
        import { Button } from '$lib/components/ui/button';
 
        let settingsOpen = $state(false);
@@ -20,4 +20,4 @@
        </div>
 </header>
 
-<ChatSettingsDialog open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
+<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte
new file mode 100644 (file)
index 0000000..ecab232
--- /dev/null
@@ -0,0 +1,136 @@
+<script lang="ts">
+       import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
+       import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+       import { slotsService } from '$lib/services/slots';
+       import { isLoading, activeMessages, activeConversation } from '$lib/stores/chat.svelte';
+       import { config } from '$lib/stores/settings.svelte';
+
+       const processingState = useProcessingState();
+
+       let isCurrentConversationLoading = $derived(isLoading());
+       let processingDetails = $derived(processingState.getProcessingDetails());
+       let showSlotsInfo = $derived(isCurrentConversationLoading || config().keepStatsVisible);
+
+       // Track loading state reactively by checking if conversation ID is in loading conversations array
+       $effect(() => {
+               const keepStatsVisible = config().keepStatsVisible;
+
+               if (keepStatsVisible || isCurrentConversationLoading) {
+                       processingState.startMonitoring();
+               }
+
+               if (!isCurrentConversationLoading && !keepStatsVisible) {
+                       setTimeout(() => {
+                               if (!config().keepStatsVisible) {
+                                       processingState.stopMonitoring();
+                               }
+                       }, PROCESSING_INFO_TIMEOUT);
+               }
+       });
+
+       // Update processing state from stored timings
+       $effect(() => {
+               const conversation = activeConversation();
+               const messages = activeMessages() as DatabaseMessage[];
+               const keepStatsVisible = config().keepStatsVisible;
+
+               if (keepStatsVisible && conversation) {
+                       if (messages.length === 0) {
+                               slotsService.clearConversationState(conversation.id);
+                               return;
+                       }
+
+                       // Search backwards through messages to find most recent assistant message with timing data
+                       // Using reverse iteration for performance - avoids array copy and stops at first match
+                       let foundTimingData = false;
+
+                       for (let i = messages.length - 1; i >= 0; i--) {
+                               const message = messages[i];
+                               if (message.role === 'assistant' && message.timings) {
+                                       foundTimingData = true;
+
+                                       slotsService
+                                               .updateFromTimingData(
+                                                       {
+                                                               prompt_n: message.timings.prompt_n || 0,
+                                                               predicted_n: message.timings.predicted_n || 0,
+                                                               predicted_per_second:
+                                                                       message.timings.predicted_n && message.timings.predicted_ms
+                                                                               ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
+                                                                               : 0,
+                                                               cache_n: message.timings.cache_n || 0
+                                                       },
+                                                       conversation.id
+                                               )
+                                               .catch((error) => {
+                                                       console.warn('Failed to update processing state from stored timings:', error);
+                                               });
+                                       break;
+                               }
+                       }
+
+                       if (!foundTimingData) {
+                               slotsService.clearConversationState(conversation.id);
+                       }
+               }
+       });
+</script>
+
+<div class="chat-processing-info-container pointer-events-none" class:visible={showSlotsInfo}>
+       <div class="chat-processing-info-content">
+               {#each processingDetails as detail (detail)}
+                       <span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
+               {/each}
+       </div>
+</div>
+
+<style>
+       .chat-processing-info-container {
+               position: sticky;
+               top: 0;
+               z-index: 10;
+               padding: 1.5rem 1rem;
+               opacity: 0;
+               transform: translateY(50%);
+               transition:
+                       opacity 300ms ease-out,
+                       transform 300ms ease-out;
+       }
+
+       .chat-processing-info-container.visible {
+               opacity: 1;
+               transform: translateY(0);
+       }
+
+       .chat-processing-info-content {
+               display: flex;
+               flex-wrap: wrap;
+               align-items: center;
+               gap: 1rem;
+               justify-content: center;
+               max-width: 48rem;
+               margin: 0 auto;
+       }
+
+       .chat-processing-info-detail {
+               color: var(--muted-foreground);
+               font-size: 0.75rem;
+               padding: 0.25rem 0.75rem;
+               background: var(--muted);
+               border-radius: 0.375rem;
+               font-family:
+                       ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
+               white-space: nowrap;
+       }
+
+       @media (max-width: 768px) {
+               .chat-processing-info-content {
+                       gap: 0.5rem;
+               }
+
+               .chat-processing-info-detail {
+                       font-size: 0.7rem;
+                       padding: 0.2rem 0.5rem;
+               }
+       }
+</style>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
new file mode 100644 (file)
index 0000000..d00ae12
--- /dev/null
@@ -0,0 +1,493 @@
+<script lang="ts">
+       import {
+               Settings,
+               Funnel,
+               AlertTriangle,
+               Brain,
+               Code,
+               Monitor,
+               Sun,
+               Moon,
+               ChevronLeft,
+               ChevronRight,
+               Database
+       } from '@lucide/svelte';
+       import {
+               ChatSettingsFooter,
+               ChatSettingsImportExportTab,
+               ChatSettingsFields
+       } from '$lib/components/app';
+       import { ScrollArea } from '$lib/components/ui/scroll-area';
+       import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
+       import { setMode } from 'mode-watcher';
+       import type { Component } from 'svelte';
+
+       interface Props {
+               onSave?: () => void;
+       }
+
+       let { onSave }: Props = $props();
+
+       const settingSections: Array<{
+               fields: SettingsFieldConfig[];
+               icon: Component;
+               title: string;
+       }> = [
+               {
+                       title: 'General',
+                       icon: Settings,
+                       fields: [
+                               { key: 'apiKey', label: 'API Key', type: 'input' },
+                               {
+                                       key: 'systemMessage',
+                                       label: 'System Message (will be disabled if left empty)',
+                                       type: 'textarea'
+                               },
+                               {
+                                       key: 'theme',
+                                       label: 'Theme',
+                                       type: 'select',
+                                       options: [
+                                               { value: 'system', label: 'System', icon: Monitor },
+                                               { value: 'light', label: 'Light', icon: Sun },
+                                               { value: 'dark', label: 'Dark', icon: Moon }
+                                       ]
+                               },
+                               {
+                                       key: 'pasteLongTextToFileLen',
+                                       label: 'Paste long text to file length',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'showMessageStats',
+                                       label: 'Show message generation statistics',
+                                       type: 'checkbox'
+                               },
+                               {
+                                       key: 'showTokensPerSecond',
+                                       label: 'Show tokens per second',
+                                       type: 'checkbox'
+                               },
+                               {
+                                       key: 'keepStatsVisible',
+                                       label: 'Keep stats visible after generation',
+                                       type: 'checkbox'
+                               },
+                               {
+                                       key: 'showModelInfo',
+                                       label: 'Show model information',
+                                       type: 'checkbox'
+                               },
+                               {
+                                       key: 'enableContinueGeneration',
+                                       label: 'Enable "Continue" button',
+                                       type: 'checkbox',
+                                       isExperimental: true
+                               },
+                               {
+                                       key: 'pdfAsImage',
+                                       label: 'Parse PDF as image',
+                                       type: 'checkbox'
+                               },
+                               {
+                                       key: 'renderUserContentAsMarkdown',
+                                       label: 'Render user content as Markdown',
+                                       type: 'checkbox'
+                               },
+                               {
+                                       key: 'askForTitleConfirmation',
+                                       label: 'Ask for confirmation before changing conversation title',
+                                       type: 'checkbox'
+                               }
+                       ]
+               },
+               {
+                       title: 'Sampling',
+                       icon: Funnel,
+                       fields: [
+                               {
+                                       key: 'temperature',
+                                       label: 'Temperature',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'dynatemp_range',
+                                       label: 'Dynamic temperature range',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'dynatemp_exponent',
+                                       label: 'Dynamic temperature exponent',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'top_k',
+                                       label: 'Top K',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'top_p',
+                                       label: 'Top P',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'min_p',
+                                       label: 'Min P',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'xtc_probability',
+                                       label: 'XTC probability',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'xtc_threshold',
+                                       label: 'XTC threshold',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'typ_p',
+                                       label: 'Typical P',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'max_tokens',
+                                       label: 'Max tokens',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'samplers',
+                                       label: 'Samplers',
+                                       type: 'input'
+                               }
+                       ]
+               },
+               {
+                       title: 'Penalties',
+                       icon: AlertTriangle,
+                       fields: [
+                               {
+                                       key: 'repeat_last_n',
+                                       label: 'Repeat last N',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'repeat_penalty',
+                                       label: 'Repeat penalty',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'presence_penalty',
+                                       label: 'Presence penalty',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'frequency_penalty',
+                                       label: 'Frequency penalty',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'dry_multiplier',
+                                       label: 'DRY multiplier',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'dry_base',
+                                       label: 'DRY base',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'dry_allowed_length',
+                                       label: 'DRY allowed length',
+                                       type: 'input'
+                               },
+                               {
+                                       key: 'dry_penalty_last_n',
+                                       label: 'DRY penalty last N',
+                                       type: 'input'
+                               }
+                       ]
+               },
+               {
+                       title: 'Reasoning',
+                       icon: Brain,
+                       fields: [
+                               {
+                                       key: 'showThoughtInProgress',
+                                       label: 'Show thought in progress',
+                                       type: 'checkbox'
+                               }
+                       ]
+               },
+               {
+                       title: 'Import/Export',
+                       icon: Database,
+                       fields: []
+               },
+               {
+                       title: 'Developer',
+                       icon: Code,
+                       fields: [
+                               {
+                                       key: 'modelSelectorEnabled',
+                                       label: 'Enable model selector',
+                                       type: 'checkbox'
+                               },
+                               {
+                                       key: 'showToolCalls',
+                                       label: 'Show tool call labels',
+                                       type: 'checkbox'
+                               },
+                               {
+                                       key: 'disableReasoningFormat',
+                                       label: 'Show raw LLM output',
+                                       type: 'checkbox'
+                               },
+                               {
+                                       key: 'custom',
+                                       label: 'Custom JSON',
+                                       type: 'textarea'
+                               }
+                       ]
+               }
+               // TODO: Experimental features section will be implemented after initial release
+               // This includes Python interpreter (Pyodide integration) and other experimental features
+               // {
+               //      title: 'Experimental',
+               //      icon: Beaker,
+               //      fields: [
+               //              {
+               //                      key: 'pyInterpreterEnabled',
+               //                      label: 'Enable Python interpreter',
+               //                      type: 'checkbox'
+               //              }
+               //      ]
+               // }
+       ];
+
+       let activeSection = $state('General');
+       let currentSection = $derived(
+               settingSections.find((section) => section.title === activeSection) || settingSections[0]
+       );
+       let localConfig: SettingsConfigType = $state({ ...config() });
+
+       let canScrollLeft = $state(false);
+       let canScrollRight = $state(false);
+       let scrollContainer: HTMLDivElement | undefined = $state();
+
+       function handleThemeChange(newTheme: string) {
+               localConfig.theme = newTheme;
+
+               setMode(newTheme as 'light' | 'dark' | 'system');
+       }
+
+       function handleConfigChange(key: string, value: string | boolean) {
+               localConfig[key] = value;
+       }
+
+       function handleReset() {
+               localConfig = { ...config() };
+
+               setMode(localConfig.theme as 'light' | 'dark' | 'system');
+       }
+
+       function handleSave() {
+               if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
+                       try {
+                               JSON.parse(localConfig.custom);
+                       } catch (error) {
+                               alert('Invalid JSON in custom parameters. Please check the format and try again.');
+                               console.error(error);
+                               return;
+                       }
+               }
+
+               // Convert numeric strings to numbers for numeric fields
+               const processedConfig = { ...localConfig };
+               const numericFields = [
+                       'temperature',
+                       'top_k',
+                       'top_p',
+                       'min_p',
+                       'max_tokens',
+                       'pasteLongTextToFileLen',
+                       'dynatemp_range',
+                       'dynatemp_exponent',
+                       'typ_p',
+                       'xtc_probability',
+                       'xtc_threshold',
+                       'repeat_last_n',
+                       'repeat_penalty',
+                       'presence_penalty',
+                       'frequency_penalty',
+                       'dry_multiplier',
+                       'dry_base',
+                       'dry_allowed_length',
+                       'dry_penalty_last_n'
+               ];
+
+               for (const field of numericFields) {
+                       if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
+                               const numValue = Number(processedConfig[field]);
+                               if (!isNaN(numValue)) {
+                                       processedConfig[field] = numValue;
+                               } else {
+                                       alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
+                                       return;
+                               }
+                       }
+               }
+
+               updateMultipleConfig(processedConfig);
+               onSave?.();
+       }
+
+       function scrollToCenter(element: HTMLElement) {
+               if (!scrollContainer) return;
+
+               const containerRect = scrollContainer.getBoundingClientRect();
+               const elementRect = element.getBoundingClientRect();
+
+               const elementCenter = elementRect.left + elementRect.width / 2;
+               const containerCenter = containerRect.left + containerRect.width / 2;
+               const scrollOffset = elementCenter - containerCenter;
+
+               scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
+       }
+
+       function scrollLeft() {
+               if (!scrollContainer) return;
+
+               scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
+       }
+
+       function scrollRight() {
+               if (!scrollContainer) return;
+
+               scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
+       }
+
+       function updateScrollButtons() {
+               if (!scrollContainer) return;
+
+               const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
+               canScrollLeft = scrollLeft > 0;
+               canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
+       }
+
+       export function reset() {
+               localConfig = { ...config() };
+
+               setTimeout(updateScrollButtons, 100);
+       }
+
+       $effect(() => {
+               if (scrollContainer) {
+                       updateScrollButtons();
+               }
+       });
+</script>
+
+<div class="flex h-full flex-col overflow-hidden md:flex-row">
+       <!-- Desktop Sidebar -->
+       <div class="hidden w-64 border-r border-border/30 p-6 md:block">
+               <nav class="space-y-1 py-2">
+                       {#each settingSections as section (section.title)}
+                               <button
+                                       class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
+                                       section.title
+                                               ? 'bg-accent text-accent-foreground'
+                                               : 'text-muted-foreground'}"
+                                       onclick={() => (activeSection = section.title)}
+                               >
+                                       <section.icon class="h-4 w-4" />
+
+                                       <span class="ml-2">{section.title}</span>
+                               </button>
+                       {/each}
+               </nav>
+       </div>
+
+       <!-- Mobile Header with Horizontal Scrollable Menu -->
+       <div class="flex flex-col md:hidden">
+               <div class="border-b border-border/30 py-4">
+                       <!-- Horizontal Scrollable Category Menu with Navigation -->
+                       <div class="relative flex items-center" style="scroll-padding: 1rem;">
+                               <button
+                                       class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
+                                               ? 'opacity-100'
+                                               : 'pointer-events-none opacity-0'}"
+                                       onclick={scrollLeft}
+                                       aria-label="Scroll left"
+                               >
+                                       <ChevronLeft class="h-4 w-4" />
+                               </button>
+
+                               <div
+                                       class="scrollbar-hide overflow-x-auto py-2"
+                                       bind:this={scrollContainer}
+                                       onscroll={updateScrollButtons}
+                               >
+                                       <div class="flex min-w-max gap-2">
+                                               {#each settingSections as section (section.title)}
+                                                       <button
+                                                               class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
+                                                               section.title
+                                                                       ? 'bg-accent text-accent-foreground'
+                                                                       : 'text-muted-foreground'}"
+                                                               onclick={(e: MouseEvent) => {
+                                                                       activeSection = section.title;
+                                                                       scrollToCenter(e.currentTarget as HTMLElement);
+                                                               }}
+                                                       >
+                                                               <section.icon class="h-4 w-4 flex-shrink-0" />
+                                                               <span>{section.title}</span>
+                                                       </button>
+                                               {/each}
+                                       </div>
+                               </div>
+
+                               <button
+                                       class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
+                                               ? 'opacity-100'
+                                               : 'pointer-events-none opacity-0'}"
+                                       onclick={scrollRight}
+                                       aria-label="Scroll right"
+                               >
+                                       <ChevronRight class="h-4 w-4" />
+                               </button>
+                       </div>
+               </div>
+       </div>
+
+       <ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
+               <div class="space-y-6 p-4 md:p-6">
+                       <div class="grid">
+                               <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
+                                       <currentSection.icon class="h-5 w-5" />
+
+                                       <h3 class="text-lg font-semibold">{currentSection.title}</h3>
+                               </div>
+
+                               {#if currentSection.title === 'Import/Export'}
+                                       <ChatSettingsImportExportTab />
+                               {:else}
+                                       <div class="space-y-6">
+                                               <ChatSettingsFields
+                                                       fields={currentSection.fields}
+                                                       {localConfig}
+                                                       onConfigChange={handleConfigChange}
+                                                       onThemeChange={handleThemeChange}
+                                               />
+                                       </div>
+                               {/if}
+                       </div>
+
+                       <div class="mt-8 border-t pt-6">
+                               <p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
+                       </div>
+               </div>
+       </ScrollArea>
+</div>
+
+<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte
deleted file mode 100644 (file)
index 6734fc0..0000000
+++ /dev/null
@@ -1,518 +0,0 @@
-<script lang="ts">
-       import {
-               Settings,
-               Funnel,
-               AlertTriangle,
-               Brain,
-               Code,
-               Monitor,
-               Sun,
-               Moon,
-               ChevronLeft,
-               ChevronRight,
-               Database
-       } from '@lucide/svelte';
-       import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
-       import ImportExportTab from './ImportExportTab.svelte';
-       import * as Dialog from '$lib/components/ui/dialog';
-       import { ScrollArea } from '$lib/components/ui/scroll-area';
-       import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
-       import { setMode } from 'mode-watcher';
-       import type { Component } from 'svelte';
-
-       interface Props {
-               onOpenChange?: (open: boolean) => void;
-               open?: boolean;
-       }
-
-       let { onOpenChange, open = false }: Props = $props();
-
-       const settingSections: Array<{
-               fields: SettingsFieldConfig[];
-               icon: Component;
-               title: string;
-       }> = [
-               {
-                       title: 'General',
-                       icon: Settings,
-                       fields: [
-                               { key: 'apiKey', label: 'API Key', type: 'input' },
-                               {
-                                       key: 'systemMessage',
-                                       label: 'System Message (will be disabled if left empty)',
-                                       type: 'textarea'
-                               },
-                               {
-                                       key: 'theme',
-                                       label: 'Theme',
-                                       type: 'select',
-                                       options: [
-                                               { value: 'system', label: 'System', icon: Monitor },
-                                               { value: 'light', label: 'Light', icon: Sun },
-                                               { value: 'dark', label: 'Dark', icon: Moon }
-                                       ]
-                               },
-                               {
-                                       key: 'pasteLongTextToFileLen',
-                                       label: 'Paste long text to file length',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'showMessageStats',
-                                       label: 'Show message generation statistics',
-                                       type: 'checkbox'
-                               },
-                               {
-                                       key: 'showTokensPerSecond',
-                                       label: 'Show tokens per second',
-                                       type: 'checkbox'
-                               },
-                               {
-                                       key: 'keepStatsVisible',
-                                       label: 'Keep stats visible after generation',
-                                       type: 'checkbox'
-                               },
-                               {
-                                       key: 'showModelInfo',
-                                       label: 'Show model information',
-                                       type: 'checkbox'
-                               },
-                               {
-                                       key: 'enableContinueGeneration',
-                                       label: 'Enable "Continue" button',
-                                       type: 'checkbox',
-                                       isExperimental: true
-                               },
-                               {
-                                       key: 'pdfAsImage',
-                                       label: 'Parse PDF as image',
-                                       type: 'checkbox'
-                               },
-                               {
-                                       key: 'renderUserContentAsMarkdown',
-                                       label: 'Render user content as Markdown',
-                                       type: 'checkbox'
-                               },
-                               {
-                                       key: 'askForTitleConfirmation',
-                                       label: 'Ask for confirmation before changing conversation title',
-                                       type: 'checkbox'
-                               }
-                       ]
-               },
-               {
-                       title: 'Sampling',
-                       icon: Funnel,
-                       fields: [
-                               {
-                                       key: 'temperature',
-                                       label: 'Temperature',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'dynatemp_range',
-                                       label: 'Dynamic temperature range',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'dynatemp_exponent',
-                                       label: 'Dynamic temperature exponent',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'top_k',
-                                       label: 'Top K',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'top_p',
-                                       label: 'Top P',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'min_p',
-                                       label: 'Min P',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'xtc_probability',
-                                       label: 'XTC probability',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'xtc_threshold',
-                                       label: 'XTC threshold',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'typ_p',
-                                       label: 'Typical P',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'max_tokens',
-                                       label: 'Max tokens',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'samplers',
-                                       label: 'Samplers',
-                                       type: 'input'
-                               }
-                       ]
-               },
-               {
-                       title: 'Penalties',
-                       icon: AlertTriangle,
-                       fields: [
-                               {
-                                       key: 'repeat_last_n',
-                                       label: 'Repeat last N',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'repeat_penalty',
-                                       label: 'Repeat penalty',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'presence_penalty',
-                                       label: 'Presence penalty',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'frequency_penalty',
-                                       label: 'Frequency penalty',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'dry_multiplier',
-                                       label: 'DRY multiplier',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'dry_base',
-                                       label: 'DRY base',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'dry_allowed_length',
-                                       label: 'DRY allowed length',
-                                       type: 'input'
-                               },
-                               {
-                                       key: 'dry_penalty_last_n',
-                                       label: 'DRY penalty last N',
-                                       type: 'input'
-                               }
-                       ]
-               },
-               {
-                       title: 'Reasoning',
-                       icon: Brain,
-                       fields: [
-                               {
-                                       key: 'showThoughtInProgress',
-                                       label: 'Show thought in progress',
-                                       type: 'checkbox'
-                               }
-                       ]
-               },
-               {
-                       title: 'Import/Export',
-                       icon: Database,
-                       fields: []
-               },
-               {
-                       title: 'Developer',
-                       icon: Code,
-                       fields: [
-                               {
-                                       key: 'modelSelectorEnabled',
-                                       label: 'Enable model selector',
-                                       type: 'checkbox'
-                               },
-                               {
-                                       key: 'showToolCalls',
-                                       label: 'Show tool call labels',
-                                       type: 'checkbox'
-                               },
-                               {
-                                       key: 'disableReasoningFormat',
-                                       label: 'Show raw LLM output',
-                                       type: 'checkbox'
-                               },
-                               {
-                                       key: 'custom',
-                                       label: 'Custom JSON',
-                                       type: 'textarea'
-                               }
-                       ]
-               }
-               // TODO: Experimental features section will be implemented after initial release
-               // This includes Python interpreter (Pyodide integration) and other experimental features
-               // {
-               //      title: 'Experimental',
-               //      icon: Beaker,
-               //      fields: [
-               //              {
-               //                      key: 'pyInterpreterEnabled',
-               //                      label: 'Enable Python interpreter',
-               //                      type: 'checkbox'
-               //              }
-               //      ]
-               // }
-       ];
-
-       let activeSection = $state('General');
-       let currentSection = $derived(
-               settingSections.find((section) => section.title === activeSection) || settingSections[0]
-       );
-       let localConfig: SettingsConfigType = $state({ ...config() });
-       let originalTheme: string = $state('');
-
-       let canScrollLeft = $state(false);
-       let canScrollRight = $state(false);
-       let scrollContainer: HTMLDivElement | undefined = $state();
-
-       function handleThemeChange(newTheme: string) {
-               localConfig.theme = newTheme;
-
-               setMode(newTheme as 'light' | 'dark' | 'system');
-       }
-
-       function handleConfigChange(key: string, value: string | boolean) {
-               localConfig[key] = value;
-       }
-
-       function handleClose() {
-               if (localConfig.theme !== originalTheme) {
-                       setMode(originalTheme as 'light' | 'dark' | 'system');
-               }
-               onOpenChange?.(false);
-       }
-
-       function handleReset() {
-               localConfig = { ...config() };
-
-               setMode(localConfig.theme as 'light' | 'dark' | 'system');
-               originalTheme = localConfig.theme as string;
-       }
-
-       function handleSave() {
-               if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
-                       try {
-                               JSON.parse(localConfig.custom);
-                       } catch (error) {
-                               alert('Invalid JSON in custom parameters. Please check the format and try again.');
-                               console.error(error);
-                               return;
-                       }
-               }
-
-               // Convert numeric strings to numbers for numeric fields
-               const processedConfig = { ...localConfig };
-               const numericFields = [
-                       'temperature',
-                       'top_k',
-                       'top_p',
-                       'min_p',
-                       'max_tokens',
-                       'pasteLongTextToFileLen',
-                       'dynatemp_range',
-                       'dynatemp_exponent',
-                       'typ_p',
-                       'xtc_probability',
-                       'xtc_threshold',
-                       'repeat_last_n',
-                       'repeat_penalty',
-                       'presence_penalty',
-                       'frequency_penalty',
-                       'dry_multiplier',
-                       'dry_base',
-                       'dry_allowed_length',
-                       'dry_penalty_last_n'
-               ];
-
-               for (const field of numericFields) {
-                       if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
-                               const numValue = Number(processedConfig[field]);
-                               if (!isNaN(numValue)) {
-                                       processedConfig[field] = numValue;
-                               } else {
-                                       alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
-                                       return;
-                               }
-                       }
-               }
-
-               updateMultipleConfig(processedConfig);
-               onOpenChange?.(false);
-       }
-
-       function scrollToCenter(element: HTMLElement) {
-               if (!scrollContainer) return;
-
-               const containerRect = scrollContainer.getBoundingClientRect();
-               const elementRect = element.getBoundingClientRect();
-
-               const elementCenter = elementRect.left + elementRect.width / 2;
-               const containerCenter = containerRect.left + containerRect.width / 2;
-               const scrollOffset = elementCenter - containerCenter;
-
-               scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
-       }
-
-       function scrollLeft() {
-               if (!scrollContainer) return;
-
-               scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
-       }
-
-       function scrollRight() {
-               if (!scrollContainer) return;
-
-               scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
-       }
-
-       function updateScrollButtons() {
-               if (!scrollContainer) return;
-
-               const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
-               canScrollLeft = scrollLeft > 0;
-               canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
-       }
-
-       $effect(() => {
-               if (open) {
-                       localConfig = { ...config() };
-                       originalTheme = config().theme as string;
-
-                       setTimeout(updateScrollButtons, 100);
-               }
-       });
-
-       $effect(() => {
-               if (scrollContainer) {
-                       updateScrollButtons();
-               }
-       });
-</script>
-
-<Dialog.Root {open} onOpenChange={handleClose}>
-       <Dialog.Content
-               class="z-999999 flex h-[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;"
-       >
-               <div class="flex flex-1 flex-col overflow-hidden md:flex-row">
-                       <!-- Desktop Sidebar -->
-                       <div class="hidden w-64 border-r border-border/30 p-6 md:block">
-                               <nav class="space-y-1 py-2">
-                                       <Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
-
-                                       {#each settingSections as section (section.title)}
-                                               <button
-                                                       class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
-                                                       section.title
-                                                               ? 'bg-accent text-accent-foreground'
-                                                               : 'text-muted-foreground'}"
-                                                       onclick={() => (activeSection = section.title)}
-                                               >
-                                                       <section.icon class="h-4 w-4" />
-
-                                                       <span class="ml-2">{section.title}</span>
-                                               </button>
-                                       {/each}
-                               </nav>
-                       </div>
-
-                       <!-- Mobile Header with Horizontal Scrollable Menu -->
-                       <div class="flex flex-col md:hidden">
-                               <div class="border-b border-border/30 py-4">
-                                       <Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title>
-
-                                       <!-- Horizontal Scrollable Category Menu with Navigation -->
-                                       <div class="relative flex items-center" style="scroll-padding: 1rem;">
-                                               <button
-                                                       class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
-                                                               ? 'opacity-100'
-                                                               : 'pointer-events-none opacity-0'}"
-                                                       onclick={scrollLeft}
-                                                       aria-label="Scroll left"
-                                               >
-                                                       <ChevronLeft class="h-4 w-4" />
-                                               </button>
-
-                                               <div
-                                                       class="scrollbar-hide overflow-x-auto py-2"
-                                                       bind:this={scrollContainer}
-                                                       onscroll={updateScrollButtons}
-                                               >
-                                                       <div class="flex min-w-max gap-2">
-                                                               {#each settingSections as section (section.title)}
-                                                                       <button
-                                                                               class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
-                                                                               section.title
-                                                                                       ? 'bg-accent text-accent-foreground'
-                                                                                       : 'text-muted-foreground'}"
-                                                                               onclick={(e: MouseEvent) => {
-                                                                                       activeSection = section.title;
-                                                                                       scrollToCenter(e.currentTarget as HTMLElement);
-                                                                               }}
-                                                                       >
-                                                                               <section.icon class="h-4 w-4 flex-shrink-0" />
-                                                                               <span>{section.title}</span>
-                                                                       </button>
-                                                               {/each}
-                                                       </div>
-                                               </div>
-
-                                               <button
-                                                       class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
-                                                               ? 'opacity-100'
-                                                               : 'pointer-events-none opacity-0'}"
-                                                       onclick={scrollRight}
-                                                       aria-label="Scroll right"
-                                               >
-                                                       <ChevronRight class="h-4 w-4" />
-                                               </button>
-                                       </div>
-                               </div>
-                       </div>
-
-                       <ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
-                               <div class="space-y-6 p-4 md:p-6">
-                                       <div class="grid">
-                                               <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
-                                                       <currentSection.icon class="h-5 w-5" />
-
-                                                       <h3 class="text-lg font-semibold">{currentSection.title}</h3>
-                                               </div>
-
-                                               {#if currentSection.title === 'Import/Export'}
-                                                       <ImportExportTab />
-                                               {:else}
-                                                       <div class="space-y-6">
-                                                               <ChatSettingsFields
-                                                                       fields={currentSection.fields}
-                                                                       {localConfig}
-                                                                       onConfigChange={handleConfigChange}
-                                                                       onThemeChange={handleThemeChange}
-                                                               />
-                                                       </div>
-                                               {/if}
-                                       </div>
-
-                                       <div class="mt-8 border-t pt-6">
-                                               <p class="text-xs text-muted-foreground">
-                                                       Settings are saved in browser's localStorage
-                                               </p>
-                                       </div>
-                               </div>
-                       </ScrollArea>
-               </div>
-
-               <ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
-       </Dialog.Content>
-</Dialog.Root>
index 992075e9728554fa8302caec1c7928a82e737ddb..8834e3e3e1cc116b3c63a8d0fbab9226d62af768 100644 (file)
@@ -9,7 +9,7 @@
        import { supportsVision } from '$lib/stores/server.svelte';
        import { getParameterInfo, resetParameterToServerDefault } from '$lib/stores/settings.svelte';
        import { ParameterSyncService } from '$lib/services/parameter-sync';
-       import ParameterSourceIndicator from './ParameterSourceIndicator.svelte';
+       import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
        import type { Component } from 'svelte';
 
        interface Props {
@@ -63,7 +63,7 @@
                                        {/if}
                                </Label>
                                {#if isCustomRealTime}
-                                       <ParameterSourceIndicator />
+                                       <ChatSettingsParameterSourceIndicator />
                                {/if}
                        </div>
 
                                        {/if}
                                </Label>
                                {#if isCustomRealTime}
-                                       <ParameterSourceIndicator />
+                                       <ChatSettingsParameterSourceIndicator />
                                {/if}
                        </div>
 
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte
new file mode 100644 (file)
index 0000000..b2adf39
--- /dev/null
@@ -0,0 +1,255 @@
+<script lang="ts">
+       import { Download, Upload } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import { DialogConversationSelection } from '$lib/components/app';
+       import { DatabaseStore } from '$lib/stores/database';
+       import type { ExportedConversations } from '$lib/types/database';
+       import { createMessageCountMap } from '$lib/utils/conversation-utils';
+       import { chatStore } from '$lib/stores/chat.svelte';
+
+       let exportedConversations = $state<DatabaseConversation[]>([]);
+       let importedConversations = $state<DatabaseConversation[]>([]);
+       let showExportSummary = $state(false);
+       let showImportSummary = $state(false);
+
+       let showExportDialog = $state(false);
+       let showImportDialog = $state(false);
+       let availableConversations = $state<DatabaseConversation[]>([]);
+       let messageCountMap = $state<Map<string, number>>(new Map());
+       let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
+               []
+       );
+
+       async function handleExportClick() {
+               try {
+                       const allConversations = await DatabaseStore.getAllConversations();
+                       if (allConversations.length === 0) {
+                               alert('No conversations to export');
+                               return;
+                       }
+
+                       const conversationsWithMessages = await Promise.all(
+                               allConversations.map(async (conv) => {
+                                       const messages = await DatabaseStore.getConversationMessages(conv.id);
+                                       return { conv, messages };
+                               })
+                       );
+
+                       messageCountMap = createMessageCountMap(conversationsWithMessages);
+                       availableConversations = allConversations;
+                       showExportDialog = true;
+               } catch (err) {
+                       console.error('Failed to load conversations:', err);
+                       alert('Failed to load conversations');
+               }
+       }
+
+       async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
+               try {
+                       const allData: ExportedConversations = await Promise.all(
+                               selectedConversations.map(async (conv) => {
+                                       const messages = await DatabaseStore.getConversationMessages(conv.id);
+                                       return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
+                               })
+                       );
+
+                       const blob = new Blob([JSON.stringify(allData, null, 2)], {
+                               type: 'application/json'
+                       });
+                       const url = URL.createObjectURL(blob);
+                       const a = document.createElement('a');
+
+                       a.href = url;
+                       a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
+                       document.body.appendChild(a);
+                       a.click();
+                       document.body.removeChild(a);
+                       URL.revokeObjectURL(url);
+
+                       exportedConversations = selectedConversations;
+                       showExportSummary = true;
+                       showImportSummary = false;
+                       showExportDialog = false;
+               } catch (err) {
+                       console.error('Export failed:', err);
+                       alert('Failed to export conversations');
+               }
+       }
+
+       async function handleImportClick() {
+               try {
+                       const input = document.createElement('input');
+
+                       input.type = 'file';
+                       input.accept = '.json';
+
+                       input.onchange = async (e) => {
+                               const file = (e.target as HTMLInputElement)?.files?.[0];
+                               if (!file) return;
+
+                               try {
+                                       const text = await file.text();
+                                       const parsedData = JSON.parse(text);
+                                       let importedData: ExportedConversations;
+
+                                       if (Array.isArray(parsedData)) {
+                                               importedData = parsedData;
+                                       } else if (
+                                               parsedData &&
+                                               typeof parsedData === 'object' &&
+                                               'conv' in parsedData &&
+                                               'messages' in parsedData
+                                       ) {
+                                               // Single conversation object
+                                               importedData = [parsedData];
+                                       } else {
+                                               throw new Error(
+                                                       'Invalid file format: expected array of conversations or single conversation object'
+                                               );
+                                       }
+
+                                       fullImportData = importedData;
+                                       availableConversations = importedData.map(
+                                               (item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
+                                       );
+                                       messageCountMap = createMessageCountMap(importedData);
+                                       showImportDialog = true;
+                               } catch (err: unknown) {
+                                       const message = err instanceof Error ? err.message : 'Unknown error';
+
+                                       console.error('Failed to parse file:', err);
+                                       alert(`Failed to parse file: ${message}`);
+                               }
+                       };
+
+                       input.click();
+               } catch (err) {
+                       console.error('Import failed:', err);
+                       alert('Failed to import conversations');
+               }
+       }
+
+       async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
+               try {
+                       const selectedIds = new Set(selectedConversations.map((c) => c.id));
+                       const selectedData = $state
+                               .snapshot(fullImportData)
+                               .filter((item) => selectedIds.has(item.conv.id));
+
+                       await DatabaseStore.importConversations(selectedData);
+
+                       await chatStore.loadConversations();
+
+                       importedConversations = selectedConversations;
+                       showImportSummary = true;
+                       showExportSummary = false;
+                       showImportDialog = false;
+               } catch (err) {
+                       console.error('Import failed:', err);
+                       alert('Failed to import conversations. Please check the file format.');
+               }
+       }
+</script>
+
+<div class="space-y-6">
+       <div class="space-y-4">
+               <div class="grid">
+                       <h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
+
+                       <p class="mb-4 text-sm text-muted-foreground">
+                               Download all your conversations as a JSON file. This includes all messages, attachments, and
+                               conversation history.
+                       </p>
+
+                       <Button
+                               class="w-full justify-start justify-self-start md:w-auto"
+                               onclick={handleExportClick}
+                               variant="outline"
+                       >
+                               <Download class="mr-2 h-4 w-4" />
+
+                               Export conversations
+                       </Button>
+
+                       {#if showExportSummary && exportedConversations.length > 0}
+                               <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+                                       <h5 class="mb-2 text-sm font-medium">
+                                               Exported {exportedConversations.length} conversation{exportedConversations.length === 1
+                                                       ? ''
+                                                       : 's'}
+                                       </h5>
+
+                                       <ul class="space-y-1 text-sm text-muted-foreground">
+                                               {#each exportedConversations.slice(0, 10) as conv (conv.id)}
+                                                       <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+                                               {/each}
+
+                                               {#if exportedConversations.length > 10}
+                                                       <li class="italic">
+                                                               ... and {exportedConversations.length - 10} more
+                                                       </li>
+                                               {/if}
+                                       </ul>
+                               </div>
+                       {/if}
+               </div>
+
+               <div class="grid border-t border-border/30 pt-4">
+                       <h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
+
+                       <p class="mb-4 text-sm text-muted-foreground">
+                               Import one or more conversations from a previously exported JSON file. This will merge with
+                               your existing conversations.
+                       </p>
+
+                       <Button
+                               class="w-full justify-start justify-self-start md:w-auto"
+                               onclick={handleImportClick}
+                               variant="outline"
+                       >
+                               <Upload class="mr-2 h-4 w-4" />
+                               Import conversations
+                       </Button>
+
+                       {#if showImportSummary && importedConversations.length > 0}
+                               <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+                                       <h5 class="mb-2 text-sm font-medium">
+                                               Imported {importedConversations.length} conversation{importedConversations.length === 1
+                                                       ? ''
+                                                       : 's'}
+                                       </h5>
+
+                                       <ul class="space-y-1 text-sm text-muted-foreground">
+                                               {#each importedConversations.slice(0, 10) as conv (conv.id)}
+                                                       <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+                                               {/each}
+
+                                               {#if importedConversations.length > 10}
+                                                       <li class="italic">
+                                                               ... and {importedConversations.length - 10} more
+                                                       </li>
+                                               {/if}
+                                       </ul>
+                               </div>
+                       {/if}
+               </div>
+       </div>
+</div>
+
+<DialogConversationSelection
+       conversations={availableConversations}
+       {messageCountMap}
+       mode="export"
+       bind:open={showExportDialog}
+       onCancel={() => (showExportDialog = false)}
+       onConfirm={handleExportConfirm}
+/>
+
+<DialogConversationSelection
+       conversations={availableConversations}
+       {messageCountMap}
+       mode="import"
+       bind:open={showImportDialog}
+       onCancel={() => (showImportDialog = false)}
+       onConfirm={handleImportConfirm}
+/>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte
new file mode 100644 (file)
index 0000000..b566985
--- /dev/null
@@ -0,0 +1,18 @@
+<script lang="ts">
+       import { Wrench } from '@lucide/svelte';
+       import { Badge } from '$lib/components/ui/badge';
+
+       interface Props {
+               class?: string;
+       }
+
+       let { class: className = '' }: Props = $props();
+</script>
+
+<Badge
+       variant="secondary"
+       class="h-5 bg-orange-100 px-1.5 py-0.5 text-xs text-orange-800 dark:bg-orange-900 dark:text-orange-200 {className}"
+>
+       <Wrench class="mr-1 h-3 w-3" />
+       Custom
+</Badge>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ConversationSelectionDialog.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ConversationSelectionDialog.svelte
deleted file mode 100644 (file)
index bc92a50..0000000
+++ /dev/null
@@ -1,249 +0,0 @@
-<script lang="ts">
-       import { Search, X } from '@lucide/svelte';
-       import * as Dialog from '$lib/components/ui/dialog';
-       import { Button } from '$lib/components/ui/button';
-       import { Input } from '$lib/components/ui/input';
-       import { Checkbox } from '$lib/components/ui/checkbox';
-       import { ScrollArea } from '$lib/components/ui/scroll-area';
-       import { SvelteSet } from 'svelte/reactivity';
-
-       interface Props {
-               conversations: DatabaseConversation[];
-               messageCountMap?: Map<string, number>;
-               mode: 'export' | 'import';
-               onCancel: () => void;
-               onConfirm: (selectedConversations: DatabaseConversation[]) => void;
-               open?: boolean;
-       }
-
-       let {
-               conversations,
-               messageCountMap = new Map(),
-               mode,
-               onCancel,
-               onConfirm,
-               open = $bindable(false)
-       }: Props = $props();
-
-       let searchQuery = $state('');
-       let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
-       let lastClickedId = $state<string | null>(null);
-
-       let filteredConversations = $derived(
-               conversations.filter((conv) => {
-                       const name = conv.name || 'Untitled conversation';
-                       return name.toLowerCase().includes(searchQuery.toLowerCase());
-               })
-       );
-
-       let allSelected = $derived(
-               filteredConversations.length > 0 &&
-                       filteredConversations.every((conv) => selectedIds.has(conv.id))
-       );
-
-       let someSelected = $derived(
-               filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
-       );
-
-       function toggleConversation(id: string, shiftKey: boolean = false) {
-               const newSet = new SvelteSet(selectedIds);
-
-               if (shiftKey && lastClickedId !== null) {
-                       const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
-                       const currentIndex = filteredConversations.findIndex((c) => c.id === id);
-
-                       if (lastIndex !== -1 && currentIndex !== -1) {
-                               const start = Math.min(lastIndex, currentIndex);
-                               const end = Math.max(lastIndex, currentIndex);
-
-                               const shouldSelect = !newSet.has(id);
-
-                               for (let i = start; i <= end; i++) {
-                                       if (shouldSelect) {
-                                               newSet.add(filteredConversations[i].id);
-                                       } else {
-                                               newSet.delete(filteredConversations[i].id);
-                                       }
-                               }
-
-                               selectedIds = newSet;
-                               return;
-                       }
-               }
-
-               if (newSet.has(id)) {
-                       newSet.delete(id);
-               } else {
-                       newSet.add(id);
-               }
-
-               selectedIds = newSet;
-               lastClickedId = id;
-       }
-
-       function toggleAll() {
-               if (allSelected) {
-                       const newSet = new SvelteSet(selectedIds);
-
-                       filteredConversations.forEach((conv) => newSet.delete(conv.id));
-                       selectedIds = newSet;
-               } else {
-                       const newSet = new SvelteSet(selectedIds);
-
-                       filteredConversations.forEach((conv) => newSet.add(conv.id));
-                       selectedIds = newSet;
-               }
-       }
-
-       function handleConfirm() {
-               const selected = conversations.filter((conv) => selectedIds.has(conv.id));
-               onConfirm(selected);
-       }
-
-       function handleCancel() {
-               selectedIds = new SvelteSet(conversations.map((c) => c.id));
-               searchQuery = '';
-               lastClickedId = null;
-
-               onCancel();
-       }
-
-       let previousOpen = $state(false);
-
-       $effect(() => {
-               if (open && !previousOpen) {
-                       selectedIds = new SvelteSet(conversations.map((c) => c.id));
-                       searchQuery = '';
-                       lastClickedId = null;
-               } else if (!open && previousOpen) {
-                       onCancel();
-               }
-
-               previousOpen = open;
-       });
-</script>
-
-<Dialog.Root bind:open>
-       <Dialog.Portal>
-               <Dialog.Overlay class="z-[1000000]" />
-
-               <Dialog.Content class="z-[1000001] max-w-2xl">
-                       <Dialog.Header>
-                               <Dialog.Title>
-                                       Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
-                               </Dialog.Title>
-
-                               <Dialog.Description>
-                                       {#if mode === 'export'}
-                                               Choose which conversations you want to export. Selected conversations will be downloaded
-                                               as a JSON file.
-                                       {:else}
-                                               Choose which conversations you want to import. Selected conversations will be merged
-                                               with your existing conversations.
-                                       {/if}
-                               </Dialog.Description>
-                       </Dialog.Header>
-
-                       <div class="space-y-4">
-                               <div class="relative">
-                                       <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
-
-                                       <Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
-
-                                       {#if searchQuery}
-                                               <button
-                                                       class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
-                                                       onclick={() => (searchQuery = '')}
-                                                       type="button"
-                                               >
-                                                       <X class="h-4 w-4" />
-                                               </button>
-                                       {/if}
-                               </div>
-
-                               <div class="flex items-center justify-between text-sm text-muted-foreground">
-                                       <span>
-                                               {selectedIds.size} of {conversations.length} selected
-                                               {#if searchQuery}
-                                                       ({filteredConversations.length} shown)
-                                               {/if}
-                                       </span>
-                               </div>
-
-                               <div class="overflow-hidden rounded-md border">
-                                       <ScrollArea class="h-[400px]">
-                                               <table class="w-full">
-                                                       <thead class="sticky top-0 z-10 bg-muted">
-                                                               <tr class="border-b">
-                                                                       <th class="w-12 p-3 text-left">
-                                                                               <Checkbox
-                                                                                       checked={allSelected}
-                                                                                       indeterminate={someSelected}
-                                                                                       onCheckedChange={toggleAll}
-                                                                               />
-                                                                       </th>
-
-                                                                       <th class="p-3 text-left text-sm font-medium">Conversation Name</th>
-
-                                                                       <th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
-                                                               </tr>
-                                                       </thead>
-                                                       <tbody>
-                                                               {#if filteredConversations.length === 0}
-                                                                       <tr>
-                                                                               <td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
-                                                                                       {#if searchQuery}
-                                                                                               No conversations found matching "{searchQuery}"
-                                                                                       {:else}
-                                                                                               No conversations available
-                                                                                       {/if}
-                                                                               </td>
-                                                                       </tr>
-                                                               {:else}
-                                                                       {#each filteredConversations as conv (conv.id)}
-                                                                               <tr
-                                                                                       class="cursor-pointer border-b transition-colors hover:bg-muted/50"
-                                                                                       onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
-                                                                               >
-                                                                                       <td class="p-3">
-                                                                                               <Checkbox
-                                                                                                       checked={selectedIds.has(conv.id)}
-                                                                                                       onclick={(e) => {
-                                                                                                               e.preventDefault();
-                                                                                                               e.stopPropagation();
-                                                                                                               toggleConversation(conv.id, e.shiftKey);
-                                                                                                       }}
-                                                                                               />
-                                                                                       </td>
-
-                                                                                       <td class="p-3 text-sm">
-                                                                                               <div
-                                                                                                       class="max-w-[17rem] truncate"
-                                                                                                       title={conv.name || 'Untitled conversation'}
-                                                                                               >
-                                                                                                       {conv.name || 'Untitled conversation'}
-                                                                                               </div>
-                                                                                       </td>
-
-                                                                                       <td class="p-3 text-sm text-muted-foreground">
-                                                                                               {messageCountMap.get(conv.id) ?? 0}
-                                                                                       </td>
-                                                                               </tr>
-                                                                       {/each}
-                                                               {/if}
-                                                       </tbody>
-                                               </table>
-                                       </ScrollArea>
-                               </div>
-                       </div>
-
-                       <Dialog.Footer>
-                               <Button variant="outline" onclick={handleCancel}>Cancel</Button>
-
-                               <Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
-                                       {mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
-                               </Button>
-                       </Dialog.Footer>
-               </Dialog.Content>
-       </Dialog.Portal>
-</Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ImportExportTab.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ImportExportTab.svelte
deleted file mode 100644 (file)
index 19c982c..0000000
+++ /dev/null
@@ -1,255 +0,0 @@
-<script lang="ts">
-       import { Download, Upload } from '@lucide/svelte';
-       import { Button } from '$lib/components/ui/button';
-       import ConversationSelectionDialog from './ConversationSelectionDialog.svelte';
-       import { DatabaseStore } from '$lib/stores/database';
-       import type { ExportedConversations } from '$lib/types/database';
-       import { createMessageCountMap } from '$lib/utils/conversation-utils';
-       import { chatStore } from '$lib/stores/chat.svelte';
-
-       let exportedConversations = $state<DatabaseConversation[]>([]);
-       let importedConversations = $state<DatabaseConversation[]>([]);
-       let showExportSummary = $state(false);
-       let showImportSummary = $state(false);
-
-       let showExportDialog = $state(false);
-       let showImportDialog = $state(false);
-       let availableConversations = $state<DatabaseConversation[]>([]);
-       let messageCountMap = $state<Map<string, number>>(new Map());
-       let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
-               []
-       );
-
-       async function handleExportClick() {
-               try {
-                       const allConversations = await DatabaseStore.getAllConversations();
-                       if (allConversations.length === 0) {
-                               alert('No conversations to export');
-                               return;
-                       }
-
-                       const conversationsWithMessages = await Promise.all(
-                               allConversations.map(async (conv) => {
-                                       const messages = await DatabaseStore.getConversationMessages(conv.id);
-                                       return { conv, messages };
-                               })
-                       );
-
-                       messageCountMap = createMessageCountMap(conversationsWithMessages);
-                       availableConversations = allConversations;
-                       showExportDialog = true;
-               } catch (err) {
-                       console.error('Failed to load conversations:', err);
-                       alert('Failed to load conversations');
-               }
-       }
-
-       async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
-               try {
-                       const allData: ExportedConversations = await Promise.all(
-                               selectedConversations.map(async (conv) => {
-                                       const messages = await DatabaseStore.getConversationMessages(conv.id);
-                                       return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
-                               })
-                       );
-
-                       const blob = new Blob([JSON.stringify(allData, null, 2)], {
-                               type: 'application/json'
-                       });
-                       const url = URL.createObjectURL(blob);
-                       const a = document.createElement('a');
-
-                       a.href = url;
-                       a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
-                       document.body.appendChild(a);
-                       a.click();
-                       document.body.removeChild(a);
-                       URL.revokeObjectURL(url);
-
-                       exportedConversations = selectedConversations;
-                       showExportSummary = true;
-                       showImportSummary = false;
-                       showExportDialog = false;
-               } catch (err) {
-                       console.error('Export failed:', err);
-                       alert('Failed to export conversations');
-               }
-       }
-
-       async function handleImportClick() {
-               try {
-                       const input = document.createElement('input');
-
-                       input.type = 'file';
-                       input.accept = '.json';
-
-                       input.onchange = async (e) => {
-                               const file = (e.target as HTMLInputElement)?.files?.[0];
-                               if (!file) return;
-
-                               try {
-                                       const text = await file.text();
-                                       const parsedData = JSON.parse(text);
-                                       let importedData: ExportedConversations;
-
-                                       if (Array.isArray(parsedData)) {
-                                               importedData = parsedData;
-                                       } else if (
-                                               parsedData &&
-                                               typeof parsedData === 'object' &&
-                                               'conv' in parsedData &&
-                                               'messages' in parsedData
-                                       ) {
-                                               // Single conversation object
-                                               importedData = [parsedData];
-                                       } else {
-                                               throw new Error(
-                                                       'Invalid file format: expected array of conversations or single conversation object'
-                                               );
-                                       }
-
-                                       fullImportData = importedData;
-                                       availableConversations = importedData.map(
-                                               (item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
-                                       );
-                                       messageCountMap = createMessageCountMap(importedData);
-                                       showImportDialog = true;
-                               } catch (err: unknown) {
-                                       const message = err instanceof Error ? err.message : 'Unknown error';
-
-                                       console.error('Failed to parse file:', err);
-                                       alert(`Failed to parse file: ${message}`);
-                               }
-                       };
-
-                       input.click();
-               } catch (err) {
-                       console.error('Import failed:', err);
-                       alert('Failed to import conversations');
-               }
-       }
-
-       async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
-               try {
-                       const selectedIds = new Set(selectedConversations.map((c) => c.id));
-                       const selectedData = $state
-                               .snapshot(fullImportData)
-                               .filter((item) => selectedIds.has(item.conv.id));
-
-                       await DatabaseStore.importConversations(selectedData);
-
-                       await chatStore.loadConversations();
-
-                       importedConversations = selectedConversations;
-                       showImportSummary = true;
-                       showExportSummary = false;
-                       showImportDialog = false;
-               } catch (err) {
-                       console.error('Import failed:', err);
-                       alert('Failed to import conversations. Please check the file format.');
-               }
-       }
-</script>
-
-<div class="space-y-6">
-       <div class="space-y-4">
-               <div class="grid">
-                       <h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
-
-                       <p class="mb-4 text-sm text-muted-foreground">
-                               Download all your conversations as a JSON file. This includes all messages, attachments, and
-                               conversation history.
-                       </p>
-
-                       <Button
-                               class="w-full justify-start justify-self-start md:w-auto"
-                               onclick={handleExportClick}
-                               variant="outline"
-                       >
-                               <Download class="mr-2 h-4 w-4" />
-
-                               Export conversations
-                       </Button>
-
-                       {#if showExportSummary && exportedConversations.length > 0}
-                               <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
-                                       <h5 class="mb-2 text-sm font-medium">
-                                               Exported {exportedConversations.length} conversation{exportedConversations.length === 1
-                                                       ? ''
-                                                       : 's'}
-                                       </h5>
-
-                                       <ul class="space-y-1 text-sm text-muted-foreground">
-                                               {#each exportedConversations.slice(0, 10) as conv (conv.id)}
-                                                       <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
-                                               {/each}
-
-                                               {#if exportedConversations.length > 10}
-                                                       <li class="italic">
-                                                               ... and {exportedConversations.length - 10} more
-                                                       </li>
-                                               {/if}
-                                       </ul>
-                               </div>
-                       {/if}
-               </div>
-
-               <div class="grid border-t border-border/30 pt-4">
-                       <h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
-
-                       <p class="mb-4 text-sm text-muted-foreground">
-                               Import one or more conversations from a previously exported JSON file. This will merge with
-                               your existing conversations.
-                       </p>
-
-                       <Button
-                               class="w-full justify-start justify-self-start md:w-auto"
-                               onclick={handleImportClick}
-                               variant="outline"
-                       >
-                               <Upload class="mr-2 h-4 w-4" />
-                               Import conversations
-                       </Button>
-
-                       {#if showImportSummary && importedConversations.length > 0}
-                               <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
-                                       <h5 class="mb-2 text-sm font-medium">
-                                               Imported {importedConversations.length} conversation{importedConversations.length === 1
-                                                       ? ''
-                                                       : 's'}
-                                       </h5>
-
-                                       <ul class="space-y-1 text-sm text-muted-foreground">
-                                               {#each importedConversations.slice(0, 10) as conv (conv.id)}
-                                                       <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
-                                               {/each}
-
-                                               {#if importedConversations.length > 10}
-                                                       <li class="italic">
-                                                               ... and {importedConversations.length - 10} more
-                                                       </li>
-                                               {/if}
-                                       </ul>
-                               </div>
-                       {/if}
-               </div>
-       </div>
-</div>
-
-<ConversationSelectionDialog
-       conversations={availableConversations}
-       {messageCountMap}
-       mode="export"
-       bind:open={showExportDialog}
-       onCancel={() => (showExportDialog = false)}
-       onConfirm={handleExportConfirm}
-/>
-
-<ConversationSelectionDialog
-       conversations={availableConversations}
-       {messageCountMap}
-       mode="import"
-       bind:open={showImportDialog}
-       onCancel={() => (showImportDialog = false)}
-       onConfirm={handleImportConfirm}
-/>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ParameterSourceIndicator.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ParameterSourceIndicator.svelte
deleted file mode 100644 (file)
index b566985..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<script lang="ts">
-       import { Wrench } from '@lucide/svelte';
-       import { Badge } from '$lib/components/ui/badge';
-
-       interface Props {
-               class?: string;
-       }
-
-       let { class: className = '' }: Props = $props();
-</script>
-
-<Badge
-       variant="secondary"
-       class="h-5 bg-orange-100 px-1.5 py-0.5 text-xs text-orange-800 dark:bg-orange-900 dark:text-orange-200 {className}"
->
-       <Wrench class="mr-1 h-3 w-3" />
-       Custom
-</Badge>
index 5976e5dd03d7b6c65b245436c76301089da058d8..34f3da53ea52443a769c67b6fee37c7fd0e3bb68 100644 (file)
@@ -2,7 +2,7 @@
        import { goto } from '$app/navigation';
        import { page } from '$app/state';
        import { Trash2 } from '@lucide/svelte';
-       import { ChatSidebarConversationItem, ConfirmationDialog } from '$lib/components/app';
+       import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
        import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
        import * as Sidebar from '$lib/components/ui/sidebar';
        import * as AlertDialog from '$lib/components/ui/alert-dialog';
        <div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div>
 </ScrollArea>
 
-<ConfirmationDialog
+<DialogConfirmation
        bind:open={showDeleteDialog}
        title="Delete Conversation"
        description={selectedConversation
diff --git a/tools/server/webui/src/lib/components/app/dialogs/ChatErrorDialog.svelte b/tools/server/webui/src/lib/components/app/dialogs/ChatErrorDialog.svelte
deleted file mode 100644 (file)
index 8ecb589..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-<script lang="ts">
-       import * as AlertDialog from '$lib/components/ui/alert-dialog';
-       import { AlertTriangle, TimerOff } from '@lucide/svelte';
-
-       interface Props {
-               open: boolean;
-               type: 'timeout' | 'server';
-               message: string;
-               onOpenChange?: (open: boolean) => void;
-       }
-
-       let { open = $bindable(), type, message, onOpenChange }: Props = $props();
-
-       const isTimeout = $derived(type === 'timeout');
-       const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
-       const description = $derived(
-               isTimeout
-                       ? 'The request did not receive a response from the server before timing out.'
-                       : 'The server responded with an error message. Review the details below.'
-       );
-       const iconClass = $derived(isTimeout ? 'text-destructive' : 'text-amber-500');
-       const badgeClass = $derived(
-               isTimeout
-                       ? 'border-destructive/40 bg-destructive/10 text-destructive'
-                       : 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
-       );
-
-       function handleOpenChange(newOpen: boolean) {
-               open = newOpen;
-               onOpenChange?.(newOpen);
-       }
-</script>
-
-<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
-       <AlertDialog.Content>
-               <AlertDialog.Header>
-                       <AlertDialog.Title class="flex items-center gap-2">
-                               {#if isTimeout}
-                                       <TimerOff class={`h-5 w-5 ${iconClass}`} />
-                               {:else}
-                                       <AlertTriangle class={`h-5 w-5 ${iconClass}`} />
-                               {/if}
-
-                               {title}
-                       </AlertDialog.Title>
-
-                       <AlertDialog.Description>
-                               {description}
-                       </AlertDialog.Description>
-               </AlertDialog.Header>
-
-               <div class={`rounded-lg border px-4 py-3 text-sm ${badgeClass}`}>
-                       <p class="font-medium">{message}</p>
-               </div>
-
-               <AlertDialog.Footer>
-                       <AlertDialog.Action onclick={() => handleOpenChange(false)}>Close</AlertDialog.Action>
-               </AlertDialog.Footer>
-       </AlertDialog.Content>
-</AlertDialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/ConfirmationDialog.svelte b/tools/server/webui/src/lib/components/app/dialogs/ConfirmationDialog.svelte
deleted file mode 100644 (file)
index b5175a9..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-<script lang="ts">
-       import * as AlertDialog from '$lib/components/ui/alert-dialog';
-       import type { Component } from 'svelte';
-
-       interface Props {
-               open: boolean;
-               title: string;
-               description: string;
-               confirmText?: string;
-               cancelText?: string;
-               variant?: 'default' | 'destructive';
-               icon?: Component;
-               onConfirm: () => void;
-               onCancel: () => void;
-               onKeydown?: (event: KeyboardEvent) => void;
-       }
-
-       let {
-               open = $bindable(),
-               title,
-               description,
-               confirmText = 'Confirm',
-               cancelText = 'Cancel',
-               variant = 'default',
-               icon,
-               onConfirm,
-               onCancel,
-               onKeydown
-       }: Props = $props();
-
-       function handleKeydown(event: KeyboardEvent) {
-               if (event.key === 'Enter') {
-                       event.preventDefault();
-                       onConfirm();
-               }
-               onKeydown?.(event);
-       }
-
-       function handleOpenChange(newOpen: boolean) {
-               if (!newOpen) {
-                       onCancel();
-               }
-       }
-</script>
-
-<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
-       <AlertDialog.Content onkeydown={handleKeydown}>
-               <AlertDialog.Header>
-                       <AlertDialog.Title class="flex items-center gap-2">
-                               {#if icon}
-                                       {@const IconComponent = icon}
-                                       <IconComponent class="h-5 w-5 {variant === 'destructive' ? 'text-destructive' : ''}" />
-                               {/if}
-                               {title}
-                       </AlertDialog.Title>
-
-                       <AlertDialog.Description>
-                               {description}
-                       </AlertDialog.Description>
-               </AlertDialog.Header>
-
-               <AlertDialog.Footer>
-                       <AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel>
-                       <AlertDialog.Action
-                               onclick={onConfirm}
-                               class={variant === 'destructive' ? 'bg-destructive text-white hover:bg-destructive/80' : ''}
-                       >
-                               {confirmText}
-                       </AlertDialog.Action>
-               </AlertDialog.Footer>
-       </AlertDialog.Content>
-</AlertDialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/ConversationTitleUpdateDialog.svelte b/tools/server/webui/src/lib/components/app/dialogs/ConversationTitleUpdateDialog.svelte
deleted file mode 100644 (file)
index 4a9ecce..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<script lang="ts">
-       import * as AlertDialog from '$lib/components/ui/alert-dialog';
-       import { Button } from '$lib/components/ui/button';
-
-       interface Props {
-               open: boolean;
-               currentTitle: string;
-               newTitle: string;
-               onConfirm: () => void;
-               onCancel: () => void;
-       }
-
-       let { open = $bindable(), currentTitle, newTitle, onConfirm, onCancel }: Props = $props();
-</script>
-
-<AlertDialog.Root bind:open>
-       <AlertDialog.Content>
-               <AlertDialog.Header>
-                       <AlertDialog.Title>Update Conversation Title?</AlertDialog.Title>
-
-                       <AlertDialog.Description>
-                               Do you want to update the conversation title to match the first message content?
-                       </AlertDialog.Description>
-               </AlertDialog.Header>
-
-               <div class="space-y-4 pt-2 pb-6">
-                       <div class="space-y-2">
-                               <p class="text-sm font-medium text-muted-foreground">Current title:</p>
-
-                               <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{currentTitle}</p>
-                       </div>
-
-                       <div class="space-y-2">
-                               <p class="text-sm font-medium text-muted-foreground">New title would be:</p>
-
-                               <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{newTitle}</p>
-                       </div>
-               </div>
-
-               <AlertDialog.Footer>
-                       <Button variant="outline" onclick={onCancel}>Keep Current Title</Button>
-
-                       <Button onclick={onConfirm}>Update Title</Button>
-               </AlertDialog.Footer>
-       </AlertDialog.Content>
-</AlertDialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte
new file mode 100644 (file)
index 0000000..ac70b8d
--- /dev/null
@@ -0,0 +1,78 @@
+<script lang="ts">
+       import * as Dialog from '$lib/components/ui/dialog';
+       import { ChatAttachmentPreview } from '$lib/components/app';
+       import { formatFileSize } from '$lib/utils/file-preview';
+
+       interface Props {
+               open: boolean;
+               // Either an uploaded file or a stored attachment
+               uploadedFile?: ChatUploadedFile;
+               attachment?: DatabaseMessageExtra;
+               // For uploaded files
+               preview?: string;
+               name?: string;
+               type?: string;
+               size?: number;
+               textContent?: string;
+       }
+
+       let {
+               open = $bindable(),
+               uploadedFile,
+               attachment,
+               preview,
+               name,
+               type,
+               size,
+               textContent
+       }: Props = $props();
+
+       let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state();
+
+       let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
+
+       let displayType = $derived(
+               uploadedFile?.type ||
+                       (attachment?.type === 'imageFile'
+                               ? 'image'
+                               : attachment?.type === 'textFile'
+                                       ? 'text'
+                                       : attachment?.type === 'audioFile'
+                                               ? attachment.mimeType || 'audio'
+                                               : attachment?.type === 'pdfFile'
+                                                       ? 'application/pdf'
+                                                       : type || 'unknown')
+       );
+
+       let displaySize = $derived(uploadedFile?.size || size);
+
+       $effect(() => {
+               if (open && chatAttachmentPreviewRef) {
+                       chatAttachmentPreviewRef.reset();
+               }
+       });
+</script>
+
+<Dialog.Root bind:open>
+       <Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
+               <Dialog.Header>
+                       <Dialog.Title>{displayName}</Dialog.Title>
+                       <Dialog.Description>
+                               {displayType}
+                               {#if displaySize}
+                                       â€¢ {formatFileSize(displaySize)}
+                               {/if}
+                       </Dialog.Description>
+               </Dialog.Header>
+
+               <ChatAttachmentPreview
+                       bind:this={chatAttachmentPreviewRef}
+                       {uploadedFile}
+                       {attachment}
+                       {preview}
+                       {name}
+                       {type}
+                       {textContent}
+               />
+       </Dialog.Content>
+</Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte
new file mode 100644 (file)
index 0000000..8f6ca76
--- /dev/null
@@ -0,0 +1,51 @@
+<script lang="ts">
+       import * as Dialog from '$lib/components/ui/dialog';
+       import { ChatAttachmentsViewAll } from '$lib/components/app';
+
+       interface Props {
+               open?: boolean;
+               uploadedFiles?: ChatUploadedFile[];
+               attachments?: DatabaseMessageExtra[];
+               readonly?: boolean;
+               onFileRemove?: (fileId: string) => void;
+               imageHeight?: string;
+               imageWidth?: string;
+               imageClass?: string;
+       }
+
+       let {
+               open = $bindable(false),
+               uploadedFiles = [],
+               attachments = [],
+               readonly = false,
+               onFileRemove,
+               imageHeight = 'h-24',
+               imageWidth = 'w-auto',
+               imageClass = ''
+       }: Props = $props();
+
+       let totalCount = $derived(uploadedFiles.length + attachments.length);
+</script>
+
+<Dialog.Root bind:open>
+       <Dialog.Portal>
+               <Dialog.Overlay />
+
+               <Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
+                       <Dialog.Header>
+                               <Dialog.Title>All Attachments ({totalCount})</Dialog.Title>
+                               <Dialog.Description>View and manage all attached files</Dialog.Description>
+                       </Dialog.Header>
+
+                       <ChatAttachmentsViewAll
+                               {uploadedFiles}
+                               {attachments}
+                               {readonly}
+                               {onFileRemove}
+                               {imageHeight}
+                               {imageWidth}
+                               {imageClass}
+                       />
+               </Dialog.Content>
+       </Dialog.Portal>
+</Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte
new file mode 100644 (file)
index 0000000..8ecb589
--- /dev/null
@@ -0,0 +1,60 @@
+<script lang="ts">
+       import * as AlertDialog from '$lib/components/ui/alert-dialog';
+       import { AlertTriangle, TimerOff } from '@lucide/svelte';
+
+       interface Props {
+               open: boolean;
+               type: 'timeout' | 'server';
+               message: string;
+               onOpenChange?: (open: boolean) => void;
+       }
+
+       let { open = $bindable(), type, message, onOpenChange }: Props = $props();
+
+       const isTimeout = $derived(type === 'timeout');
+       const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
+       const description = $derived(
+               isTimeout
+                       ? 'The request did not receive a response from the server before timing out.'
+                       : 'The server responded with an error message. Review the details below.'
+       );
+       const iconClass = $derived(isTimeout ? 'text-destructive' : 'text-amber-500');
+       const badgeClass = $derived(
+               isTimeout
+                       ? 'border-destructive/40 bg-destructive/10 text-destructive'
+                       : 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
+       );
+
+       function handleOpenChange(newOpen: boolean) {
+               open = newOpen;
+               onOpenChange?.(newOpen);
+       }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+       <AlertDialog.Content>
+               <AlertDialog.Header>
+                       <AlertDialog.Title class="flex items-center gap-2">
+                               {#if isTimeout}
+                                       <TimerOff class={`h-5 w-5 ${iconClass}`} />
+                               {:else}
+                                       <AlertTriangle class={`h-5 w-5 ${iconClass}`} />
+                               {/if}
+
+                               {title}
+                       </AlertDialog.Title>
+
+                       <AlertDialog.Description>
+                               {description}
+                       </AlertDialog.Description>
+               </AlertDialog.Header>
+
+               <div class={`rounded-lg border px-4 py-3 text-sm ${badgeClass}`}>
+                       <p class="font-medium">{message}</p>
+               </div>
+
+               <AlertDialog.Footer>
+                       <AlertDialog.Action onclick={() => handleOpenChange(false)}>Close</AlertDialog.Action>
+               </AlertDialog.Footer>
+       </AlertDialog.Content>
+</AlertDialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte
new file mode 100644 (file)
index 0000000..e9aaa10
--- /dev/null
@@ -0,0 +1,37 @@
+<script lang="ts">
+       import * as Dialog from '$lib/components/ui/dialog';
+       import { ChatSettings } from '$lib/components/app';
+
+       interface Props {
+               onOpenChange?: (open: boolean) => void;
+               open?: boolean;
+       }
+
+       let { onOpenChange, open = false }: Props = $props();
+
+       let chatSettingsRef: ChatSettings | undefined = $state();
+
+       function handleClose() {
+               onOpenChange?.(false);
+       }
+
+       function handleSave() {
+               onOpenChange?.(false);
+       }
+
+       $effect(() => {
+               if (open && chatSettingsRef) {
+                       chatSettingsRef.reset();
+               }
+       });
+</script>
+
+<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;"
+       >
+               <ChatSettings bind:this={chatSettingsRef} onSave={handleSave} />
+       </Dialog.Content>
+</Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte
new file mode 100644 (file)
index 0000000..b5175a9
--- /dev/null
@@ -0,0 +1,72 @@
+<script lang="ts">
+       import * as AlertDialog from '$lib/components/ui/alert-dialog';
+       import type { Component } from 'svelte';
+
+       interface Props {
+               open: boolean;
+               title: string;
+               description: string;
+               confirmText?: string;
+               cancelText?: string;
+               variant?: 'default' | 'destructive';
+               icon?: Component;
+               onConfirm: () => void;
+               onCancel: () => void;
+               onKeydown?: (event: KeyboardEvent) => void;
+       }
+
+       let {
+               open = $bindable(),
+               title,
+               description,
+               confirmText = 'Confirm',
+               cancelText = 'Cancel',
+               variant = 'default',
+               icon,
+               onConfirm,
+               onCancel,
+               onKeydown
+       }: Props = $props();
+
+       function handleKeydown(event: KeyboardEvent) {
+               if (event.key === 'Enter') {
+                       event.preventDefault();
+                       onConfirm();
+               }
+               onKeydown?.(event);
+       }
+
+       function handleOpenChange(newOpen: boolean) {
+               if (!newOpen) {
+                       onCancel();
+               }
+       }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+       <AlertDialog.Content onkeydown={handleKeydown}>
+               <AlertDialog.Header>
+                       <AlertDialog.Title class="flex items-center gap-2">
+                               {#if icon}
+                                       {@const IconComponent = icon}
+                                       <IconComponent class="h-5 w-5 {variant === 'destructive' ? 'text-destructive' : ''}" />
+                               {/if}
+                               {title}
+                       </AlertDialog.Title>
+
+                       <AlertDialog.Description>
+                               {description}
+                       </AlertDialog.Description>
+               </AlertDialog.Header>
+
+               <AlertDialog.Footer>
+                       <AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel>
+                       <AlertDialog.Action
+                               onclick={onConfirm}
+                               class={variant === 'destructive' ? 'bg-destructive text-white hover:bg-destructive/80' : ''}
+                       >
+                               {confirmText}
+                       </AlertDialog.Action>
+               </AlertDialog.Footer>
+       </AlertDialog.Content>
+</AlertDialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte
new file mode 100644 (file)
index 0000000..1f8ea64
--- /dev/null
@@ -0,0 +1,68 @@
+<script lang="ts">
+       import * as Dialog from '$lib/components/ui/dialog';
+       import { ConversationSelection } from '$lib/components/app';
+
+       interface Props {
+               conversations: DatabaseConversation[];
+               messageCountMap?: Map<string, number>;
+               mode: 'export' | 'import';
+               onCancel: () => void;
+               onConfirm: (selectedConversations: DatabaseConversation[]) => void;
+               open?: boolean;
+       }
+
+       let {
+               conversations,
+               messageCountMap = new Map(),
+               mode,
+               onCancel,
+               onConfirm,
+               open = $bindable(false)
+       }: Props = $props();
+
+       let conversationSelectionRef: ConversationSelection | undefined = $state();
+
+       let previousOpen = $state(false);
+
+       $effect(() => {
+               if (open && !previousOpen && conversationSelectionRef) {
+                       conversationSelectionRef.reset();
+               } else if (!open && previousOpen) {
+                       onCancel();
+               }
+
+               previousOpen = open;
+       });
+</script>
+
+<Dialog.Root bind:open>
+       <Dialog.Portal>
+               <Dialog.Overlay class="z-[1000000]" />
+
+               <Dialog.Content class="z-[1000001] max-w-2xl">
+                       <Dialog.Header>
+                               <Dialog.Title>
+                                       Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
+                               </Dialog.Title>
+                               <Dialog.Description>
+                                       {#if mode === 'export'}
+                                               Choose which conversations you want to export. Selected conversations will be downloaded
+                                               as a JSON file.
+                                       {:else}
+                                               Choose which conversations you want to import. Selected conversations will be merged
+                                               with your existing conversations.
+                                       {/if}
+                               </Dialog.Description>
+                       </Dialog.Header>
+
+                       <ConversationSelection
+                               bind:this={conversationSelectionRef}
+                               {conversations}
+                               {messageCountMap}
+                               {mode}
+                               {onCancel}
+                               {onConfirm}
+                       />
+               </Dialog.Content>
+       </Dialog.Portal>
+</Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte
new file mode 100644 (file)
index 0000000..4a9ecce
--- /dev/null
@@ -0,0 +1,46 @@
+<script lang="ts">
+       import * as AlertDialog from '$lib/components/ui/alert-dialog';
+       import { Button } from '$lib/components/ui/button';
+
+       interface Props {
+               open: boolean;
+               currentTitle: string;
+               newTitle: string;
+               onConfirm: () => void;
+               onCancel: () => void;
+       }
+
+       let { open = $bindable(), currentTitle, newTitle, onConfirm, onCancel }: Props = $props();
+</script>
+
+<AlertDialog.Root bind:open>
+       <AlertDialog.Content>
+               <AlertDialog.Header>
+                       <AlertDialog.Title>Update Conversation Title?</AlertDialog.Title>
+
+                       <AlertDialog.Description>
+                               Do you want to update the conversation title to match the first message content?
+                       </AlertDialog.Description>
+               </AlertDialog.Header>
+
+               <div class="space-y-4 pt-2 pb-6">
+                       <div class="space-y-2">
+                               <p class="text-sm font-medium text-muted-foreground">Current title:</p>
+
+                               <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{currentTitle}</p>
+                       </div>
+
+                       <div class="space-y-2">
+                               <p class="text-sm font-medium text-muted-foreground">New title would be:</p>
+
+                               <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{newTitle}</p>
+                       </div>
+               </div>
+
+               <AlertDialog.Footer>
+                       <Button variant="outline" onclick={onCancel}>Keep Current Title</Button>
+
+                       <Button onclick={onConfirm}>Update Title</Button>
+               </AlertDialog.Footer>
+       </AlertDialog.Content>
+</AlertDialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte
new file mode 100644 (file)
index 0000000..f875b0a
--- /dev/null
@@ -0,0 +1,61 @@
+<script lang="ts">
+       import * as AlertDialog from '$lib/components/ui/alert-dialog';
+       import { FileX } from '@lucide/svelte';
+
+       interface Props {
+               open: boolean;
+               emptyFiles: string[];
+               onOpenChange?: (open: boolean) => void;
+       }
+
+       let { open = $bindable(), emptyFiles, onOpenChange }: Props = $props();
+
+       function handleOpenChange(newOpen: boolean) {
+               open = newOpen;
+               onOpenChange?.(newOpen);
+       }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+       <AlertDialog.Content>
+               <AlertDialog.Header>
+                       <AlertDialog.Title class="flex items-center gap-2">
+                               <FileX class="h-5 w-5 text-destructive" />
+
+                               Empty Files Detected
+                       </AlertDialog.Title>
+
+                       <AlertDialog.Description>
+                               The following files are empty and have been removed from your attachments:
+                       </AlertDialog.Description>
+               </AlertDialog.Header>
+
+               <div class="space-y-3 text-sm">
+                       <div class="rounded-lg bg-muted p-3">
+                               <div class="mb-2 font-medium">Empty Files:</div>
+
+                               <ul class="list-inside list-disc space-y-1 text-muted-foreground">
+                                       {#each emptyFiles as fileName (fileName)}
+                                               <li class="font-mono text-sm">{fileName}</li>
+                                       {/each}
+                               </ul>
+                       </div>
+
+                       <div>
+                               <div class="mb-2 font-medium">What happened:</div>
+
+                               <ul class="list-inside list-disc space-y-1 text-muted-foreground">
+                                       <li>Empty files cannot be processed or sent to the AI model</li>
+
+                                       <li>These files have been automatically removed from your attachments</li>
+
+                                       <li>You can try uploading files with content instead</li>
+                               </ul>
+                       </div>
+               </div>
+
+               <AlertDialog.Footer>
+                       <AlertDialog.Action onclick={() => handleOpenChange(false)}>Got it</AlertDialog.Action>
+               </AlertDialog.Footer>
+       </AlertDialog.Content>
+</AlertDialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/EmptyFileAlertDialog.svelte b/tools/server/webui/src/lib/components/app/dialogs/EmptyFileAlertDialog.svelte
deleted file mode 100644 (file)
index f875b0a..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-<script lang="ts">
-       import * as AlertDialog from '$lib/components/ui/alert-dialog';
-       import { FileX } from '@lucide/svelte';
-
-       interface Props {
-               open: boolean;
-               emptyFiles: string[];
-               onOpenChange?: (open: boolean) => void;
-       }
-
-       let { open = $bindable(), emptyFiles, onOpenChange }: Props = $props();
-
-       function handleOpenChange(newOpen: boolean) {
-               open = newOpen;
-               onOpenChange?.(newOpen);
-       }
-</script>
-
-<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
-       <AlertDialog.Content>
-               <AlertDialog.Header>
-                       <AlertDialog.Title class="flex items-center gap-2">
-                               <FileX class="h-5 w-5 text-destructive" />
-
-                               Empty Files Detected
-                       </AlertDialog.Title>
-
-                       <AlertDialog.Description>
-                               The following files are empty and have been removed from your attachments:
-                       </AlertDialog.Description>
-               </AlertDialog.Header>
-
-               <div class="space-y-3 text-sm">
-                       <div class="rounded-lg bg-muted p-3">
-                               <div class="mb-2 font-medium">Empty Files:</div>
-
-                               <ul class="list-inside list-disc space-y-1 text-muted-foreground">
-                                       {#each emptyFiles as fileName (fileName)}
-                                               <li class="font-mono text-sm">{fileName}</li>
-                                       {/each}
-                               </ul>
-                       </div>
-
-                       <div>
-                               <div class="mb-2 font-medium">What happened:</div>
-
-                               <ul class="list-inside list-disc space-y-1 text-muted-foreground">
-                                       <li>Empty files cannot be processed or sent to the AI model</li>
-
-                                       <li>These files have been automatically removed from your attachments</li>
-
-                                       <li>You can try uploading files with content instead</li>
-                               </ul>
-                       </div>
-               </div>
-
-               <AlertDialog.Footer>
-                       <AlertDialog.Action onclick={() => handleOpenChange(false)}>Got it</AlertDialog.Action>
-               </AlertDialog.Footer>
-       </AlertDialog.Content>
-</AlertDialog.Root>
index a695f99747494fd1cadeb3efb139313138c26a7c..54bd8d5aa355fd460f6a7b82e6c3de7553f363da 100644 (file)
@@ -1,56 +1,63 @@
+// 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 ChatAttachmentFilePreview } from './chat/ChatAttachments/ChatAttachmentFilePreview.svelte';
-export { default as ChatAttachmentImagePreview } from './chat/ChatAttachments/ChatAttachmentImagePreview.svelte';
-export { default as ChatAttachmentPreviewDialog } from './chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte';
-export { default as ChatAttachmentsViewAllDialog } from './chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte';
+export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
 
 export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
-export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
-export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions.svelte';
-export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActionFileAttachments.svelte';
-export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActionRecord.svelte';
-export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
-export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.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';
 export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
+export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
+export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
+export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
 
 export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
 export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
+export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
 export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
-export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
-
-export { default as ChatProcessingInfo } from './chat/ChatProcessingInfo.svelte';
 
+export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
 export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
+export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
 export { default as ChatScreenWarning } from './chat/ChatScreen/ChatScreenWarning.svelte';
-export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
 
-export { default as ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.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 ImportExportTab } from './chat/ChatSettings/ImportExportTab.svelte';
-export { default as ConversationSelectionDialog } from './chat/ChatSettings/ConversationSelectionDialog.svelte';
-export { default as ParameterSourceIndicator } from './chat/ChatSettings/ParameterSourceIndicator.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 ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
 export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
-export { default as ChatErrorDialog } from './dialogs/ChatErrorDialog.svelte';
-export { default as EmptyFileAlertDialog } from './dialogs/EmptyFileAlertDialog.svelte';
 
-export { default as ConversationTitleUpdateDialog } from './dialogs/ConversationTitleUpdateDialog.svelte';
+// Dialogs
 
-export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
+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 DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
+export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
+export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
+export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
 
-export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
+// Miscellanous
 
+export { default as ActionButton } from './misc/ActionButton.svelte';
+export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
+export { default as ConversationSelection } from './misc/ConversationSelection.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';
 
+// 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';
 export { default as ServerInfo } from './server/ServerInfo.svelte';
-
-// Shared components
-export { default as ActionButton } from './misc/ActionButton.svelte';
-export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
-export { default as ConfirmationDialog } from './dialogs/ConfirmationDialog.svelte';
diff --git a/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte b/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte
new file mode 100644 (file)
index 0000000..e2095e0
--- /dev/null
@@ -0,0 +1,205 @@
+<script lang="ts">
+       import { Search, X } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import { Input } from '$lib/components/ui/input';
+       import { Checkbox } from '$lib/components/ui/checkbox';
+       import { ScrollArea } from '$lib/components/ui/scroll-area';
+       import { SvelteSet } from 'svelte/reactivity';
+
+       interface Props {
+               conversations: DatabaseConversation[];
+               messageCountMap?: Map<string, number>;
+               mode: 'export' | 'import';
+               onCancel: () => void;
+               onConfirm: (selectedConversations: DatabaseConversation[]) => void;
+       }
+
+       let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
+
+       let searchQuery = $state('');
+       let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
+       let lastClickedId = $state<string | null>(null);
+
+       let filteredConversations = $derived(
+               conversations.filter((conv) => {
+                       const name = conv.name || 'Untitled conversation';
+                       return name.toLowerCase().includes(searchQuery.toLowerCase());
+               })
+       );
+
+       let allSelected = $derived(
+               filteredConversations.length > 0 &&
+                       filteredConversations.every((conv) => selectedIds.has(conv.id))
+       );
+
+       let someSelected = $derived(
+               filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
+       );
+
+       function toggleConversation(id: string, shiftKey: boolean = false) {
+               const newSet = new SvelteSet(selectedIds);
+
+               if (shiftKey && lastClickedId !== null) {
+                       const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
+                       const currentIndex = filteredConversations.findIndex((c) => c.id === id);
+
+                       if (lastIndex !== -1 && currentIndex !== -1) {
+                               const start = Math.min(lastIndex, currentIndex);
+                               const end = Math.max(lastIndex, currentIndex);
+
+                               const shouldSelect = !newSet.has(id);
+
+                               for (let i = start; i <= end; i++) {
+                                       if (shouldSelect) {
+                                               newSet.add(filteredConversations[i].id);
+                                       } else {
+                                               newSet.delete(filteredConversations[i].id);
+                                       }
+                               }
+
+                               selectedIds = newSet;
+                               return;
+                       }
+               }
+
+               if (newSet.has(id)) {
+                       newSet.delete(id);
+               } else {
+                       newSet.add(id);
+               }
+
+               selectedIds = newSet;
+               lastClickedId = id;
+       }
+
+       function toggleAll() {
+               if (allSelected) {
+                       const newSet = new SvelteSet(selectedIds);
+
+                       filteredConversations.forEach((conv) => newSet.delete(conv.id));
+                       selectedIds = newSet;
+               } else {
+                       const newSet = new SvelteSet(selectedIds);
+
+                       filteredConversations.forEach((conv) => newSet.add(conv.id));
+                       selectedIds = newSet;
+               }
+       }
+
+       function handleConfirm() {
+               const selected = conversations.filter((conv) => selectedIds.has(conv.id));
+               onConfirm(selected);
+       }
+
+       function handleCancel() {
+               selectedIds = new SvelteSet(conversations.map((c) => c.id));
+               searchQuery = '';
+               lastClickedId = null;
+
+               onCancel();
+       }
+
+       export function reset() {
+               selectedIds = new SvelteSet(conversations.map((c) => c.id));
+               searchQuery = '';
+               lastClickedId = null;
+       }
+</script>
+
+<div class="space-y-4">
+       <div class="relative">
+               <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+
+               <Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
+
+               {#if searchQuery}
+                       <button
+                               class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                               onclick={() => (searchQuery = '')}
+                               type="button"
+                       >
+                               <X class="h-4 w-4" />
+                       </button>
+               {/if}
+       </div>
+
+       <div class="flex items-center justify-between text-sm text-muted-foreground">
+               <span>
+                       {selectedIds.size} of {conversations.length} selected
+                       {#if searchQuery}
+                               ({filteredConversations.length} shown)
+                       {/if}
+               </span>
+       </div>
+
+       <div class="overflow-hidden rounded-md border">
+               <ScrollArea class="h-[400px]">
+                       <table class="w-full">
+                               <thead class="sticky top-0 z-10 bg-muted">
+                                       <tr class="border-b">
+                                               <th class="w-12 p-3 text-left">
+                                                       <Checkbox
+                                                               checked={allSelected}
+                                                               indeterminate={someSelected}
+                                                               onCheckedChange={toggleAll}
+                                                       />
+                                               </th>
+
+                                               <th class="p-3 text-left text-sm font-medium">Conversation Name</th>
+
+                                               <th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
+                                       </tr>
+                               </thead>
+                               <tbody>
+                                       {#if filteredConversations.length === 0}
+                                               <tr>
+                                                       <td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
+                                                               {#if searchQuery}
+                                                                       No conversations found matching "{searchQuery}"
+                                                               {:else}
+                                                                       No conversations available
+                                                               {/if}
+                                                       </td>
+                                               </tr>
+                                       {:else}
+                                               {#each filteredConversations as conv (conv.id)}
+                                                       <tr
+                                                               class="cursor-pointer border-b transition-colors hover:bg-muted/50"
+                                                               onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
+                                                       >
+                                                               <td class="p-3">
+                                                                       <Checkbox
+                                                                               checked={selectedIds.has(conv.id)}
+                                                                               onclick={(e) => {
+                                                                                       e.preventDefault();
+                                                                                       e.stopPropagation();
+                                                                                       toggleConversation(conv.id, e.shiftKey);
+                                                                               }}
+                                                                       />
+                                                               </td>
+
+                                                               <td class="p-3 text-sm">
+                                                                       <div class="max-w-[17rem] truncate" title={conv.name || 'Untitled conversation'}>
+                                                                               {conv.name || 'Untitled conversation'}
+                                                                       </div>
+                                                               </td>
+
+                                                               <td class="p-3 text-sm text-muted-foreground">
+                                                                       {messageCountMap.get(conv.id) ?? 0}
+                                                               </td>
+                                                       </tr>
+                                               {/each}
+                                       {/if}
+                               </tbody>
+                       </table>
+               </ScrollArea>
+       </div>
+
+       <div class="flex justify-end gap-2">
+               <Button variant="outline" onclick={handleCancel}>Cancel</Button>
+
+               <Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
+                       {mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
+               </Button>
+       </div>
+</div>
index b08bd59c15e841b4a69dd50784c4354949a67936..dfe094c07947c6c2ded091ccbad6cd553f1b376c 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts">
        import '../app.css';
        import { page } from '$app/state';
-       import { ChatSidebar, ConversationTitleUpdateDialog } from '$lib/components/app';
+       import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
        import {
                activeMessages,
                isLoading,
 
 <Toaster richColors />
 
-<ConversationTitleUpdateDialog
+<DialogConversationTitleUpdate
        bind:open={titleUpdateDialogOpen}
        currentTitle={titleUpdateCurrentTitle}
        newTitle={titleUpdateNewTitle}
diff --git a/tools/server/webui/src/stories/ChatSettings.stories.svelte b/tools/server/webui/src/stories/ChatSettings.stories.svelte
new file mode 100644 (file)
index 0000000..4d8dbe5
--- /dev/null
@@ -0,0 +1,19 @@
+<script module>
+       import { defineMeta } from '@storybook/addon-svelte-csf';
+       import { ChatSettings } from '$lib/components/app';
+       import { fn } from 'storybook/test';
+
+       const { Story } = defineMeta({
+               title: 'Components/ChatSettings',
+               component: ChatSettings,
+               parameters: {
+                       layout: 'fullscreen'
+               },
+               args: {
+                       onClose: fn(),
+                       onSave: fn()
+               }
+       });
+</script>
+
+<Story name="Default" />
diff --git a/tools/server/webui/src/stories/ChatSettingsDialog.stories.svelte b/tools/server/webui/src/stories/ChatSettingsDialog.stories.svelte
deleted file mode 100644 (file)
index 1e53f70..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<script module>
-       import { defineMeta } from '@storybook/addon-svelte-csf';
-       import { ChatSettingsDialog } from '$lib/components/app';
-       import { fn } from 'storybook/test';
-
-       const { Story } = defineMeta({
-               title: 'Components/ChatSettingsDialog',
-               component: ChatSettingsDialog,
-               parameters: {
-                       layout: 'fullscreen'
-               },
-               argTypes: {
-                       open: {
-                               control: 'boolean',
-                               description: 'Whether the dialog is open'
-                       }
-               },
-               args: {
-                       onOpenChange: fn()
-               }
-       });
-</script>
-
-<Story name="Open" args={{ open: true }} />
-
-<Story name="Closed" args={{ open: false }} />