aboutsummaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments
diff options
context:
space:
mode:
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte283
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte165
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte64
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte243
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte117
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 @@
1<script lang="ts">
2 import { Button } from '$lib/components/ui/button';
3 import * as Alert from '$lib/components/ui/alert';
4 import { SyntaxHighlightedCode } from '$lib/components/app';
5 import { FileText, Image, Music, FileIcon, Eye, Info } from '@lucide/svelte';
6 import {
7 isTextFile,
8 isImageFile,
9 isPdfFile,
10 isAudioFile,
11 getLanguageFromFilename
12 } from '$lib/utils';
13 import { convertPDFToImage } from '$lib/utils/browser-only';
14 import { modelsStore } from '$lib/stores/models.svelte';
15
16 interface Props {
17 // Either an uploaded file or a stored attachment
18 uploadedFile?: ChatUploadedFile;
19 attachment?: DatabaseMessageExtra;
20 // For uploaded files
21 preview?: string;
22 name?: string;
23 textContent?: string;
24 // For checking vision modality
25 activeModelId?: string;
26 }
27
28 let { uploadedFile, attachment, preview, name, textContent, activeModelId }: Props = $props();
29
30 let hasVisionModality = $derived(
31 activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
32 );
33
34 let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
35
36 // Determine file type from uploaded file or attachment
37 let isAudio = $derived(isAudioFile(attachment, uploadedFile));
38 let isImage = $derived(isImageFile(attachment, uploadedFile));
39 let isPdf = $derived(isPdfFile(attachment, uploadedFile));
40 let isText = $derived(isTextFile(attachment, uploadedFile));
41
42 let displayPreview = $derived(
43 uploadedFile?.preview ||
44 (isImage && attachment && 'base64Url' in attachment ? attachment.base64Url : preview)
45 );
46
47 let displayTextContent = $derived(
48 uploadedFile?.textContent ||
49 (attachment && 'content' in attachment ? attachment.content : textContent)
50 );
51
52 let language = $derived(getLanguageFromFilename(displayName));
53
54 let IconComponent = $derived(() => {
55 if (isImage) return Image;
56 if (isText || isPdf) return FileText;
57 if (isAudio) return Music;
58
59 return FileIcon;
60 });
61
62 let pdfViewMode = $state<'text' | 'pages'>('pages');
63
64 let pdfImages = $state<string[]>([]);
65
66 let pdfImagesLoading = $state(false);
67
68 let pdfImagesError = $state<string | null>(null);
69
70 async function loadPdfImages() {
71 if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
72
73 pdfImagesLoading = true;
74 pdfImagesError = null;
75
76 try {
77 let file: File | null = null;
78
79 if (uploadedFile?.file) {
80 file = uploadedFile.file;
81 } else if (isPdf && attachment) {
82 // Check if we have pre-processed images
83 if (
84 'images' in attachment &&
85 attachment.images &&
86 Array.isArray(attachment.images) &&
87 attachment.images.length > 0
88 ) {
89 pdfImages = attachment.images;
90 return;
91 }
92
93 // Convert base64 back to File for processing
94 if ('base64Data' in attachment && attachment.base64Data) {
95 const base64Data = attachment.base64Data;
96 const byteCharacters = atob(base64Data);
97 const byteNumbers = new Array(byteCharacters.length);
98 for (let i = 0; i < byteCharacters.length; i++) {
99 byteNumbers[i] = byteCharacters.charCodeAt(i);
100 }
101 const byteArray = new Uint8Array(byteNumbers);
102 file = new File([byteArray], displayName, { type: 'application/pdf' });
103 }
104 }
105
106 if (file) {
107 pdfImages = await convertPDFToImage(file);
108 } else {
109 throw new Error('No PDF file available for conversion');
110 }
111 } catch (error) {
112 pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
113 } finally {
114 pdfImagesLoading = false;
115 }
116 }
117
118 export function reset() {
119 pdfImages = [];
120 pdfImagesLoading = false;
121 pdfImagesError = null;
122 pdfViewMode = 'pages';
123 }
124
125 $effect(() => {
126 if (isPdf && pdfViewMode === 'pages') {
127 loadPdfImages();
128 }
129 });
130</script>
131
132<div class="space-y-4">
133 <div class="flex items-center justify-end gap-6">
134 {#if isPdf}
135 <div class="flex items-center gap-2">
136 <Button
137 variant={pdfViewMode === 'text' ? 'default' : 'outline'}
138 size="sm"
139 onclick={() => (pdfViewMode = 'text')}
140 disabled={pdfImagesLoading}
141 >
142 <FileText class="mr-1 h-4 w-4" />
143
144 Text
145 </Button>
146
147 <Button
148 variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
149 size="sm"
150 onclick={() => {
151 pdfViewMode = 'pages';
152 loadPdfImages();
153 }}
154 disabled={pdfImagesLoading}
155 >
156 {#if pdfImagesLoading}
157 <div
158 class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
159 ></div>
160 {:else}
161 <Eye class="mr-1 h-4 w-4" />
162 {/if}
163
164 Pages
165 </Button>
166 </div>
167 {/if}
168 </div>
169
170 <div class="flex-1 overflow-auto">
171 {#if isImage && displayPreview}
172 <div class="flex items-center justify-center">
173 <img
174 src={displayPreview}
175 alt={displayName}
176 class="max-h-full rounded-lg object-contain shadow-lg"
177 />
178 </div>
179 {:else if isPdf && pdfViewMode === 'pages'}
180 {#if !hasVisionModality && activeModelId}
181 <Alert.Root class="mb-4">
182 <Info class="h-4 w-4" />
183 <Alert.Title>Preview only</Alert.Title>
184 <Alert.Description>
185 <span class="inline-flex">
186 The selected model does not support vision. Only the extracted
187 <!-- svelte-ignore a11y_click_events_have_key_events -->
188 <!-- svelte-ignore a11y_no_static_element_interactions -->
189 <span class="mx-1 cursor-pointer underline" onclick={() => (pdfViewMode = 'text')}>
190 text
191 </span>
192 will be sent to the model.
193 </span>
194 </Alert.Description>
195 </Alert.Root>
196 {/if}
197
198 {#if pdfImagesLoading}
199 <div class="flex items-center justify-center p-8">
200 <div class="text-center">
201 <div
202 class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
203 ></div>
204
205 <p class="text-muted-foreground">Converting PDF to images...</p>
206 </div>
207 </div>
208 {:else if pdfImagesError}
209 <div class="flex items-center justify-center p-8">
210 <div class="text-center">
211 <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
212
213 <p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
214
215 <p class="text-sm text-muted-foreground">{pdfImagesError}</p>
216
217 <Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
218 </div>
219 </div>
220 {:else if pdfImages.length > 0}
221 <div class="max-h-[70vh] space-y-4 overflow-auto">
222 {#each pdfImages as image, index (image)}
223 <div class="text-center">
224 <p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
225
226 <img
227 src={image}
228 alt="PDF Page {index + 1}"
229 class="mx-auto max-w-full rounded-lg shadow-lg"
230 />
231 </div>
232 {/each}
233 </div>
234 {:else}
235 <div class="flex items-center justify-center p-8">
236 <div class="text-center">
237 <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
238
239 <p class="mb-4 text-muted-foreground">No PDF pages available</p>
240 </div>
241 </div>
242 {/if}
243 {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
244 <SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" />
245 {:else if isAudio}
246 <div class="flex items-center justify-center p-8">
247 <div class="w-full max-w-md text-center">
248 <Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
249
250 {#if uploadedFile?.preview}
251 <audio controls class="mb-4 w-full" src={uploadedFile.preview}>
252 Your browser does not support the audio element.
253 </audio>
254 {:else if isAudio && attachment && 'mimeType' in attachment && 'base64Data' in attachment}
255 <audio
256 controls
257 class="mb-4 w-full"
258 src={`data:${attachment.mimeType};base64,${attachment.base64Data}`}
259 >
260 Your browser does not support the audio element.
261 </audio>
262 {:else}
263 <p class="mb-4 text-muted-foreground">Audio preview not available</p>
264 {/if}
265
266 <p class="text-sm text-muted-foreground">
267 {displayName}
268 </p>
269 </div>
270 </div>
271 {:else}
272 <div class="flex items-center justify-center p-8">
273 <div class="text-center">
274 {#if IconComponent}
275 <IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
276 {/if}
277
278 <p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
279 </div>
280 </div>
281 {/if}
282 </div>
283</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 @@
1<script lang="ts">
2 import { RemoveButton } from '$lib/components/app';
3 import { formatFileSize, getFileTypeLabel, getPreviewText, isTextFile } from '$lib/utils';
4 import { AttachmentType } from '$lib/enums';
5
6 interface Props {
7 class?: string;
8 id: string;
9 onClick?: (event?: MouseEvent) => void;
10 onRemove?: (id: string) => void;
11 name: string;
12 readonly?: boolean;
13 size?: number;
14 textContent?: string;
15 // Either uploaded file or stored attachment
16 uploadedFile?: ChatUploadedFile;
17 attachment?: DatabaseMessageExtra;
18 }
19
20 let {
21 class: className = '',
22 id,
23 onClick,
24 onRemove,
25 name,
26 readonly = false,
27 size,
28 textContent,
29 uploadedFile,
30 attachment
31 }: Props = $props();
32
33 let isText = $derived(isTextFile(attachment, uploadedFile));
34
35 let fileTypeLabel = $derived.by(() => {
36 if (uploadedFile?.type) {
37 return getFileTypeLabel(uploadedFile.type);
38 }
39
40 if (attachment) {
41 if ('mimeType' in attachment && attachment.mimeType) {
42 return getFileTypeLabel(attachment.mimeType);
43 }
44
45 if (attachment.type) {
46 return getFileTypeLabel(attachment.type);
47 }
48 }
49
50 return getFileTypeLabel(name);
51 });
52
53 let pdfProcessingMode = $derived.by(() => {
54 if (attachment?.type === AttachmentType.PDF) {
55 const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
56
57 return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
58 }
59 return null;
60 });
61</script>
62
63{#if isText}
64 {#if readonly}
65 <!-- Readonly mode (ChatMessage) -->
66 <button
67 class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
68 onclick={onClick}
69 aria-label={`Preview ${name}`}
70 type="button"
71 >
72 <div class="flex items-start gap-3">
73 <div class="flex min-w-0 flex-1 flex-col items-start text-left">
74 <span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
75
76 {#if size}
77 <span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
78 {/if}
79
80 {#if textContent}
81 <div class="relative mt-2 w-full">
82 <div
83 class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
84 >
85 {getPreviewText(textContent)}
86 </div>
87
88 {#if textContent.length > 150}
89 <div
90 class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent"
91 ></div>
92 {/if}
93 </div>
94 {/if}
95 </div>
96 </div>
97 </button>
98 {:else}
99 <!-- Non-readonly mode (ChatForm) -->
100 <button
101 class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
102 ? 'max-h-24 max-w-72'
103 : 'max-w-36'} cursor-pointer text-left"
104 onclick={onClick}
105 >
106 <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
107 <RemoveButton {id} {onRemove} />
108 </div>
109
110 <div class="pr-8">
111 <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
112
113 {#if textContent}
114 <div class="relative">
115 <div
116 class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
117 style="max-height: 3rem; line-height: 1.2em;"
118 >
119 {getPreviewText(textContent)}
120 </div>
121
122 {#if textContent.length > 150}
123 <div
124 class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent"
125 ></div>
126 {/if}
127 </div>
128 {/if}
129 </div>
130 </button>
131 {/if}
132{:else}
133 <button
134 class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
135 onclick={onClick}
136 >
137 <div
138 class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
139 >
140 {fileTypeLabel}
141 </div>
142
143 <div class="flex flex-col gap-0.5">
144 <span
145 class="max-w-24 truncate text-sm font-medium text-foreground {readonly
146 ? ''
147 : 'group-hover:pr-6'} md:max-w-32"
148 >
149 {name}
150 </span>
151
152 {#if pdfProcessingMode}
153 <span class="text-left text-xs text-muted-foreground">{pdfProcessingMode}</span>
154 {:else if size}
155 <span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
156 {/if}
157 </div>
158
159 {#if !readonly}
160 <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
161 <RemoveButton {id} {onRemove} />
162 </div>
163 {/if}
164 </button>
165{/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 @@
1<script lang="ts">
2 import { RemoveButton } from '$lib/components/app';
3
4 interface Props {
5 id: string;
6 name: string;
7 preview: string;
8 readonly?: boolean;
9 onRemove?: (id: string) => void;
10 onClick?: (event?: MouseEvent) => void;
11 class?: string;
12 // Customizable size props
13 width?: string;
14 height?: string;
15 imageClass?: string;
16 }
17
18 let {
19 id,
20 name,
21 preview,
22 readonly = false,
23 onRemove,
24 onClick,
25 class: className = '',
26 // Default to small size for form previews
27 width = 'w-auto',
28 height = 'h-16',
29 imageClass = ''
30 }: Props = $props();
31</script>
32
33<div
34 class="group relative overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}"
35>
36 {#if onClick}
37 <button
38 type="button"
39 class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
40 onclick={onClick}
41 aria-label="Preview {name}"
42 >
43 <img
44 src={preview}
45 alt={name}
46 class="{height} {width} cursor-pointer object-cover {imageClass}"
47 />
48 </button>
49 {:else}
50 <img
51 src={preview}
52 alt={name}
53 class="{height} {width} cursor-pointer object-cover {imageClass}"
54 />
55 {/if}
56
57 {#if !readonly}
58 <div
59 class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
60 >
61 <RemoveButton {id} {onRemove} class="text-white" />
62 </div>
63 {/if}
64</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 @@
1<script lang="ts">
2 import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
3 import { Button } from '$lib/components/ui/button';
4 import { ChevronLeft, ChevronRight } from '@lucide/svelte';
5 import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
6 import { getAttachmentDisplayItems } from '$lib/utils';
7
8 interface Props {
9 class?: string;
10 style?: string;
11 // For ChatMessage - stored attachments
12 attachments?: DatabaseMessageExtra[];
13 readonly?: boolean;
14 // For ChatForm - pending uploads
15 onFileRemove?: (fileId: string) => void;
16 uploadedFiles?: ChatUploadedFile[];
17 // Image size customization
18 imageClass?: string;
19 imageHeight?: string;
20 imageWidth?: string;
21 // Limit display to single row with "+ X more" button
22 limitToSingleRow?: boolean;
23 // For vision modality check
24 activeModelId?: string;
25 }
26
27 let {
28 class: className = '',
29 style = '',
30 attachments = [],
31 readonly = false,
32 onFileRemove,
33 uploadedFiles = $bindable([]),
34 // Default to small size for form previews
35 imageClass = '',
36 imageHeight = 'h-24',
37 imageWidth = 'w-auto',
38 limitToSingleRow = false,
39 activeModelId
40 }: Props = $props();
41
42 let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
43
44 let canScrollLeft = $state(false);
45 let canScrollRight = $state(false);
46 let isScrollable = $state(false);
47 let previewDialogOpen = $state(false);
48 let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
49 let scrollContainer: HTMLDivElement | undefined = $state();
50 let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
51 let viewAllDialogOpen = $state(false);
52
53 function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
54 event?.stopPropagation();
55 event?.preventDefault();
56
57 previewItem = {
58 uploadedFile: item.uploadedFile,
59 attachment: item.attachment,
60 preview: item.preview,
61 name: item.name,
62 size: item.size,
63 textContent: item.textContent
64 };
65 previewDialogOpen = true;
66 }
67
68 function scrollLeft(event?: MouseEvent) {
69 event?.stopPropagation();
70 event?.preventDefault();
71
72 if (!scrollContainer) return;
73
74 scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
75 }
76
77 function scrollRight(event?: MouseEvent) {
78 event?.stopPropagation();
79 event?.preventDefault();
80
81 if (!scrollContainer) return;
82
83 scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
84 }
85
86 function updateScrollButtons() {
87 if (!scrollContainer) return;
88
89 const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
90
91 canScrollLeft = scrollLeft > 0;
92 canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
93 isScrollable = scrollWidth > clientWidth;
94 }
95
96 $effect(() => {
97 if (scrollContainer && displayItems.length) {
98 scrollContainer.scrollLeft = 0;
99
100 setTimeout(() => {
101 updateScrollButtons();
102 }, 0);
103 }
104 });
105</script>
106
107{#if displayItems.length > 0}
108 <div class={className} {style}>
109 {#if limitToSingleRow}
110 <div class="relative">
111 <button
112 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
113 ? 'opacity-100'
114 : 'pointer-events-none opacity-0'}"
115 onclick={scrollLeft}
116 aria-label="Scroll left"
117 >
118 <ChevronLeft class="h-4 w-4" />
119 </button>
120
121 <div
122 class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
123 bind:this={scrollContainer}
124 onscroll={updateScrollButtons}
125 >
126 {#each displayItems as item (item.id)}
127 {#if item.isImage && item.preview}
128 <ChatAttachmentThumbnailImage
129 class="flex-shrink-0 cursor-pointer {limitToSingleRow
130 ? 'first:ml-4 last:mr-4'
131 : ''}"
132 id={item.id}
133 name={item.name}
134 preview={item.preview}
135 {readonly}
136 onRemove={onFileRemove}
137 height={imageHeight}
138 width={imageWidth}
139 {imageClass}
140 onClick={(event) => openPreview(item, event)}
141 />
142 {:else}
143 <ChatAttachmentThumbnailFile
144 class="flex-shrink-0 cursor-pointer {limitToSingleRow
145 ? 'first:ml-4 last:mr-4'
146 : ''}"
147 id={item.id}
148 name={item.name}
149 size={item.size}
150 {readonly}
151 onRemove={onFileRemove}
152 textContent={item.textContent}
153 attachment={item.attachment}
154 uploadedFile={item.uploadedFile}
155 onClick={(event) => openPreview(item, event)}
156 />
157 {/if}
158 {/each}
159 </div>
160
161 <button
162 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
163 ? 'opacity-100'
164 : 'pointer-events-none opacity-0'}"
165 onclick={scrollRight}
166 aria-label="Scroll right"
167 >
168 <ChevronRight class="h-4 w-4" />
169 </button>
170 </div>
171
172 {#if showViewAll}
173 <div class="mt-2 -mr-2 flex justify-end px-4">
174 <Button
175 type="button"
176 variant="ghost"
177 size="sm"
178 class="h-6 text-xs text-muted-foreground hover:text-foreground"
179 onclick={() => (viewAllDialogOpen = true)}
180 >
181 View all ({displayItems.length})
182 </Button>
183 </div>
184 {/if}
185 {:else}
186 <div class="flex flex-wrap items-start justify-end gap-3">
187 {#each displayItems as item (item.id)}
188 {#if item.isImage && item.preview}
189 <ChatAttachmentThumbnailImage
190 class="cursor-pointer"
191 id={item.id}
192 name={item.name}
193 preview={item.preview}
194 {readonly}
195 onRemove={onFileRemove}
196 height={imageHeight}
197 width={imageWidth}
198 {imageClass}
199 onClick={(event) => openPreview(item, event)}
200 />
201 {:else}
202 <ChatAttachmentThumbnailFile
203 class="cursor-pointer"
204 id={item.id}
205 name={item.name}
206 size={item.size}
207 {readonly}
208 onRemove={onFileRemove}
209 textContent={item.textContent}
210 attachment={item.attachment}
211 uploadedFile={item.uploadedFile}
212 onClick={(event?: MouseEvent) => openPreview(item, event)}
213 />
214 {/if}
215 {/each}
216 </div>
217 {/if}
218 </div>
219{/if}
220
221{#if previewItem}
222 <DialogChatAttachmentPreview
223 bind:open={previewDialogOpen}
224 uploadedFile={previewItem.uploadedFile}
225 attachment={previewItem.attachment}
226 preview={previewItem.preview}
227 name={previewItem.name}
228 size={previewItem.size}
229 textContent={previewItem.textContent}
230 {activeModelId}
231 />
232{/if}
233
234<DialogChatAttachmentsViewAll
235 bind:open={viewAllDialogOpen}
236 {uploadedFiles}
237 {attachments}
238 {readonly}
239 {onFileRemove}
240 imageHeight="h-64"
241 {imageClass}
242 {activeModelId}
243/>
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 @@
1<script lang="ts">
2 import {
3 ChatAttachmentThumbnailImage,
4 ChatAttachmentThumbnailFile,
5 DialogChatAttachmentPreview
6 } from '$lib/components/app';
7 import { getAttachmentDisplayItems } from '$lib/utils';
8
9 interface Props {
10 uploadedFiles?: ChatUploadedFile[];
11 attachments?: DatabaseMessageExtra[];
12 readonly?: boolean;
13 onFileRemove?: (fileId: string) => void;
14 imageHeight?: string;
15 imageWidth?: string;
16 imageClass?: string;
17 activeModelId?: string;
18 }
19
20 let {
21 uploadedFiles = [],
22 attachments = [],
23 readonly = false,
24 onFileRemove,
25 imageHeight = 'h-24',
26 imageWidth = 'w-auto',
27 imageClass = '',
28 activeModelId
29 }: Props = $props();
30
31 let previewDialogOpen = $state(false);
32 let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
33
34 let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
35 let imageItems = $derived(displayItems.filter((item) => item.isImage));
36 let fileItems = $derived(displayItems.filter((item) => !item.isImage));
37
38 function openPreview(item: (typeof displayItems)[0], event?: Event) {
39 if (event) {
40 event.preventDefault();
41 event.stopPropagation();
42 }
43
44 previewItem = {
45 uploadedFile: item.uploadedFile,
46 attachment: item.attachment,
47 preview: item.preview,
48 name: item.name,
49 size: item.size,
50 textContent: item.textContent
51 };
52 previewDialogOpen = true;
53 }
54</script>
55
56<div class="space-y-4">
57 <div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
58 {#if fileItems.length > 0}
59 <div>
60 <h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
61 <div class="flex flex-wrap items-start gap-3">
62 {#each fileItems as item (item.id)}
63 <ChatAttachmentThumbnailFile
64 class="cursor-pointer"
65 id={item.id}
66 name={item.name}
67 size={item.size}
68 {readonly}
69 onRemove={onFileRemove}
70 textContent={item.textContent}
71 attachment={item.attachment}
72 uploadedFile={item.uploadedFile}
73 onClick={(event?: MouseEvent) => openPreview(item, event)}
74 />
75 {/each}
76 </div>
77 </div>
78 {/if}
79
80 {#if imageItems.length > 0}
81 <div>
82 <h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
83 <div class="flex flex-wrap items-start gap-3">
84 {#each imageItems as item (item.id)}
85 {#if item.preview}
86 <ChatAttachmentThumbnailImage
87 class="cursor-pointer"
88 id={item.id}
89 name={item.name}
90 preview={item.preview}
91 {readonly}
92 onRemove={onFileRemove}
93 height={imageHeight}
94 width={imageWidth}
95 {imageClass}
96 onClick={(event) => openPreview(item, event)}
97 />
98 {/if}
99 {/each}
100 </div>
101 </div>
102 {/if}
103 </div>
104</div>
105
106{#if previewItem}
107 <DialogChatAttachmentPreview
108 bind:open={previewDialogOpen}
109 uploadedFile={previewItem.uploadedFile}
110 attachment={previewItem.attachment}
111 preview={previewItem.preview}
112 name={previewItem.name}
113 size={previewItem.size}
114 textContent={previewItem.textContent}
115 {activeModelId}
116 />
117{/if}