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>