diff options
| author | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
|---|---|---|
| committer | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
| commit | b333b06772c89d96aacb5490d6a219fba7c09cc6 (patch) | |
| tree | 211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments | |
| download | llmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz | |
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments')
5 files changed, 872 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte new file mode 100644 index 0000000..0b0bf52 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte @@ -0,0 +1,283 @@ +<script lang="ts"> + import { Button } from '$lib/components/ui/button'; + import * as Alert from '$lib/components/ui/alert'; + import { SyntaxHighlightedCode } from '$lib/components/app'; + import { FileText, Image, Music, FileIcon, Eye, Info } from '@lucide/svelte'; + import { + isTextFile, + isImageFile, + isPdfFile, + isAudioFile, + getLanguageFromFilename + } from '$lib/utils'; + import { convertPDFToImage } from '$lib/utils/browser-only'; + import { modelsStore } from '$lib/stores/models.svelte'; + + interface Props { + // Either an uploaded file or a stored attachment + uploadedFile?: ChatUploadedFile; + attachment?: DatabaseMessageExtra; + // For uploaded files + preview?: string; + name?: string; + textContent?: string; + // For checking vision modality + activeModelId?: string; + } + + let { uploadedFile, attachment, preview, name, textContent, activeModelId }: Props = $props(); + + let hasVisionModality = $derived( + activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false + ); + + let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File'); + + // Determine file type from uploaded file or attachment + let isAudio = $derived(isAudioFile(attachment, uploadedFile)); + let isImage = $derived(isImageFile(attachment, uploadedFile)); + let isPdf = $derived(isPdfFile(attachment, uploadedFile)); + let isText = $derived(isTextFile(attachment, uploadedFile)); + + let displayPreview = $derived( + uploadedFile?.preview || + (isImage && attachment && 'base64Url' in attachment ? attachment.base64Url : preview) + ); + + let displayTextContent = $derived( + uploadedFile?.textContent || + (attachment && 'content' in attachment ? attachment.content : textContent) + ); + + let language = $derived(getLanguageFromFilename(displayName)); + + 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 (isPdf && attachment) { + // Check if we have pre-processed images + if ( + 'images' in attachment && + attachment.images && + Array.isArray(attachment.images) && + attachment.images.length > 0 + ) { + pdfImages = attachment.images; + return; + } + + // Convert base64 back to File for processing + if ('base64Data' in attachment && 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: 'application/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 !hasVisionModality && activeModelId} + <Alert.Root class="mb-4"> + <Info class="h-4 w-4" /> + <Alert.Title>Preview only</Alert.Title> + <Alert.Description> + <span class="inline-flex"> + The selected model does not support vision. Only the extracted + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <span class="mx-1 cursor-pointer underline" onclick={() => (pdfViewMode = 'text')}> + text + </span> + will be sent to the model. + </span> + </Alert.Description> + </Alert.Root> + {/if} + + {#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} + <SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" /> + {: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 uploadedFile?.preview} + <audio controls class="mb-4 w-full" src={uploadedFile.preview}> + Your browser does not support the audio element. + </audio> + {:else if isAudio && attachment && 'mimeType' in attachment && 'base64Data' in attachment} + <audio + controls + class="mb-4 w-full" + src={`data:${attachment.mimeType};base64,${attachment.base64Data}`} + > + 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/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte new file mode 100644 index 0000000..908db58 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte @@ -0,0 +1,165 @@ +<script lang="ts"> + import { RemoveButton } from '$lib/components/app'; + import { formatFileSize, getFileTypeLabel, getPreviewText, isTextFile } from '$lib/utils'; + import { AttachmentType } from '$lib/enums'; + + interface Props { + class?: string; + id: string; + onClick?: (event?: MouseEvent) => void; + onRemove?: (id: string) => void; + name: string; + readonly?: boolean; + size?: number; + textContent?: string; + // Either uploaded file or stored attachment + uploadedFile?: ChatUploadedFile; + attachment?: DatabaseMessageExtra; + } + + let { + class: className = '', + id, + onClick, + onRemove, + name, + readonly = false, + size, + textContent, + uploadedFile, + attachment + }: Props = $props(); + + let isText = $derived(isTextFile(attachment, uploadedFile)); + + let fileTypeLabel = $derived.by(() => { + if (uploadedFile?.type) { + return getFileTypeLabel(uploadedFile.type); + } + + if (attachment) { + if ('mimeType' in attachment && attachment.mimeType) { + return getFileTypeLabel(attachment.mimeType); + } + + if (attachment.type) { + return getFileTypeLabel(attachment.type); + } + } + + return getFileTypeLabel(name); + }); + + let pdfProcessingMode = $derived.by(() => { + if (attachment?.type === AttachmentType.PDF) { + const pdfAttachment = attachment as DatabaseMessageExtraPdfFile; + + return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text'; + } + return null; + }); +</script> + +{#if isText} + {#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} + <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" + > + {fileTypeLabel} + </div> + + <div class="flex flex-col gap-0.5"> + <span + class="max-w-24 truncate text-sm font-medium text-foreground {readonly + ? '' + : 'group-hover:pr-6'} md:max-w-32" + > + {name} + </span> + + {#if pdfProcessingMode} + <span class="text-left text-xs text-muted-foreground">{pdfProcessingMode}</span> + {:else 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/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte new file mode 100644 index 0000000..ba711a9 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte @@ -0,0 +1,64 @@ +<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 bg-muted shadow-lg dark:border dark:border-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/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte new file mode 100644 index 0000000..a1f5af5 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte @@ -0,0 +1,243 @@ +<script lang="ts"> + import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app'; + import { Button } from '$lib/components/ui/button'; + import { ChevronLeft, ChevronRight } from '@lucide/svelte'; + import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app'; + import { getAttachmentDisplayItems } from '$lib/utils'; + + interface Props { + class?: string; + style?: string; + // For ChatMessage - stored attachments + attachments?: DatabaseMessageExtra[]; + readonly?: boolean; + // For ChatForm - pending uploads + onFileRemove?: (fileId: string) => void; + uploadedFiles?: ChatUploadedFile[]; + // Image size customization + imageClass?: string; + imageHeight?: string; + imageWidth?: string; + // Limit display to single row with "+ X more" button + limitToSingleRow?: boolean; + // For vision modality check + activeModelId?: string; + } + + let { + class: className = '', + style = '', + attachments = [], + readonly = false, + onFileRemove, + uploadedFiles = $bindable([]), + // Default to small size for form previews + imageClass = '', + imageHeight = 'h-24', + imageWidth = 'w-auto', + limitToSingleRow = false, + activeModelId + }: Props = $props(); + + let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments })); + + let canScrollLeft = $state(false); + let canScrollRight = $state(false); + let isScrollable = $state(false); + let previewDialogOpen = $state(false); + let previewItem = $state<ChatAttachmentPreviewItem | null>(null); + let scrollContainer: HTMLDivElement | undefined = $state(); + let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable); + let viewAllDialogOpen = $state(false); + + function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) { + event?.stopPropagation(); + event?.preventDefault(); + + previewItem = { + uploadedFile: item.uploadedFile, + attachment: item.attachment, + preview: item.preview, + name: item.name, + size: item.size, + textContent: item.textContent + }; + previewDialogOpen = true; + } + + function scrollLeft(event?: MouseEvent) { + event?.stopPropagation(); + event?.preventDefault(); + + if (!scrollContainer) return; + + scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' }); + } + + function scrollRight(event?: MouseEvent) { + event?.stopPropagation(); + event?.preventDefault(); + + if (!scrollContainer) return; + + scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' }); + } + + function updateScrollButtons() { + if (!scrollContainer) return; + + const { scrollLeft, scrollWidth, clientWidth } = scrollContainer; + + canScrollLeft = scrollLeft > 0; + canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; + isScrollable = scrollWidth > clientWidth; + } + + $effect(() => { + if (scrollContainer && displayItems.length) { + scrollContainer.scrollLeft = 0; + + setTimeout(() => { + updateScrollButtons(); + }, 0); + } + }); +</script> + +{#if displayItems.length > 0} + <div class={className} {style}> + {#if limitToSingleRow} + <div class="relative"> + <button + class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {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 flex items-start gap-3 overflow-x-auto" + bind:this={scrollContainer} + onscroll={updateScrollButtons} + > + {#each displayItems as item (item.id)} + {#if item.isImage && item.preview} + <ChatAttachmentThumbnailImage + class="flex-shrink-0 cursor-pointer {limitToSingleRow + ? 'first:ml-4 last:mr-4' + : ''}" + id={item.id} + name={item.name} + preview={item.preview} + {readonly} + onRemove={onFileRemove} + height={imageHeight} + width={imageWidth} + {imageClass} + onClick={(event) => openPreview(item, event)} + /> + {:else} + <ChatAttachmentThumbnailFile + class="flex-shrink-0 cursor-pointer {limitToSingleRow + ? 'first:ml-4 last:mr-4' + : ''}" + id={item.id} + name={item.name} + size={item.size} + {readonly} + onRemove={onFileRemove} + textContent={item.textContent} + attachment={item.attachment} + uploadedFile={item.uploadedFile} + onClick={(event) => openPreview(item, event)} + /> + {/if} + {/each} + </div> + + <button + class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight + ? 'opacity-100' + : 'pointer-events-none opacity-0'}" + onclick={scrollRight} + aria-label="Scroll right" + > + <ChevronRight class="h-4 w-4" /> + </button> + </div> + + {#if showViewAll} + <div class="mt-2 -mr-2 flex justify-end px-4"> + <Button + type="button" + variant="ghost" + size="sm" + class="h-6 text-xs text-muted-foreground hover:text-foreground" + onclick={() => (viewAllDialogOpen = true)} + > + View all ({displayItems.length}) + </Button> + </div> + {/if} + {:else} + <div class="flex flex-wrap items-start justify-end gap-3"> + {#each displayItems as item (item.id)} + {#if item.isImage && 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)} + /> + {:else} + <ChatAttachmentThumbnailFile + class="cursor-pointer" + id={item.id} + name={item.name} + size={item.size} + {readonly} + onRemove={onFileRemove} + textContent={item.textContent} + attachment={item.attachment} + uploadedFile={item.uploadedFile} + onClick={(event?: MouseEvent) => openPreview(item, event)} + /> + {/if} + {/each} + </div> + {/if} + </div> +{/if} + +{#if previewItem} + <DialogChatAttachmentPreview + bind:open={previewDialogOpen} + uploadedFile={previewItem.uploadedFile} + attachment={previewItem.attachment} + preview={previewItem.preview} + name={previewItem.name} + size={previewItem.size} + textContent={previewItem.textContent} + {activeModelId} + /> +{/if} + +<DialogChatAttachmentsViewAll + bind:open={viewAllDialogOpen} + {uploadedFiles} + {attachments} + {readonly} + {onFileRemove} + imageHeight="h-64" + {imageClass} + {activeModelId} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte new file mode 100644 index 0000000..279b2e2 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte @@ -0,0 +1,117 @@ +<script lang="ts"> + import { + ChatAttachmentThumbnailImage, + ChatAttachmentThumbnailFile, + DialogChatAttachmentPreview + } from '$lib/components/app'; + import { getAttachmentDisplayItems } from '$lib/utils'; + + interface Props { + uploadedFiles?: ChatUploadedFile[]; + attachments?: DatabaseMessageExtra[]; + readonly?: boolean; + onFileRemove?: (fileId: string) => void; + imageHeight?: string; + imageWidth?: string; + imageClass?: string; + activeModelId?: string; + } + + let { + uploadedFiles = [], + attachments = [], + readonly = false, + onFileRemove, + imageHeight = 'h-24', + imageWidth = 'w-auto', + imageClass = '', + activeModelId + }: Props = $props(); + + let previewDialogOpen = $state(false); + let previewItem = $state<ChatAttachmentPreviewItem | null>(null); + + let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments })); + let imageItems = $derived(displayItems.filter((item) => item.isImage)); + let fileItems = $derived(displayItems.filter((item) => !item.isImage)); + + 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, + 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} + size={item.size} + {readonly} + onRemove={onFileRemove} + textContent={item.textContent} + attachment={item.attachment} + uploadedFile={item.uploadedFile} + onClick={(event?: MouseEvent) => 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} + size={previewItem.size} + textContent={previewItem.textContent} + {activeModelId} + /> +{/if} |
