Commit efbf6e80a for llama.cpp
commit efbf6e80a5c121584736b5face7cfced9da707dd
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Wed Nov 4 14:46:51 2026 +0100
ui: show text and page thumbnails for PDF attachments, stop endless conversion retries (#31468)
* fix(ui): show extracted text in PDF attachment preview
Assisted-by: Claude (Opus 5.5 Medium)
* feat(ui): show page thumbnails for PDF attachments sent as images
Assisted-by: Claude (Opus 5.5 Medium)
* ui: stop retrying PDF preview conversion after it fails
A failed PDF-to-image conversion or text extraction reset its loading
flag, which re-ran the effect and retried forever. Remember the failed
item for each view and only retry when the item changes, so the error
state stays on screen.
Add a PdfConversionError story that feeds a broken PDF.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: keep the text of PDFs sent as images and tidy the PDF preview
Store the extracted text of a PDF sent as images in its content, so the
Text view reads it instead of parsing the PDF again; only messages stored
with the old placeholder extract it. The model still receives the images
only. Guard retries on the error state, as the item remounts per id, skip
an empty base64 payload, and share the status markup between views.
Assisted-by: Claude (Opus 5.5 Medium)
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte
index 754856d60..b62cbc2db 100644
--- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte
@@ -106,18 +106,22 @@
{readonly}
width={imageWidth}
/>
-{:else if isPdfFile(item.attachment, item.uploadedFile)}
- <ChatAttachmentsListItemThumbnailFile
- attachment={item.attachment}
- class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
+{:else if isPdfFile(item.attachment, item.uploadedFile) && item.preview}
+ {@const pageCount =
+ item.attachment?.type === AttachmentType.PDF ? (item.attachment.images?.length ?? 0) : 0}
+
+ <ChatAttachmentsListItemThumbnailImage
+ class="{layoutClasses} cursor-pointer {className} {scrollClasses}"
+ height={imageHeight}
id={item.id}
+ {imageClass}
+ label={pageCount > 0 ? `PDF, ${pageCount} ${pageCount === 1 ? 'page' : 'pages'}` : 'PDF'}
name={item.name}
onRemove={onFileRemove}
onclick={() => onPreview?.(item)}
+ preview={item.preview}
{readonly}
- size={item.size}
- textContent={item.textContent}
- uploadedFile={item.uploadedFile}
+ width={imageWidth}
/>
{:else}
<ChatAttachmentsListItemThumbnailFile
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte
index 79a8f115b..ba89ae246 100644
--- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte
@@ -7,6 +7,7 @@
height?: string;
id: string;
imageClass?: string;
+ label?: string;
onclick?: (event?: MouseEvent) => void;
onRemove?: (id: string) => void;
name: string;
@@ -20,6 +21,7 @@
height = 'h-16',
id,
imageClass = '',
+ label,
name,
onclick,
onRemove,
@@ -49,6 +51,14 @@
{@render image()}
{/if}
+ {#if label}
+ <span
+ class="pointer-events-none absolute bottom-1 left-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white"
+ >
+ {label}
+ </span>
+ {/if}
+
{#if !readonly}
<div
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte
index efcf1975c..1193f0c6d 100644
--- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte
@@ -32,6 +32,7 @@
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
+ isPdf: boolean;
}
interface Props {
@@ -58,6 +59,7 @@
...item,
isAudio: isAudioFile(item.attachment, item.uploadedFile),
isImage: isImageFile(item.attachment, item.uploadedFile),
+ isPdf: isPdfFile(item.attachment, item.uploadedFile),
isVideo: isVideoFile(item.attachment, item.uploadedFile)
})
)
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte
index 6b7fad627..2389a460c 100644
--- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte
@@ -3,11 +3,11 @@
import { SyntaxHighlightedCode } from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
- import { ICON_CLASS_DEFAULT } from '$lib/constants';
- import { PdfViewMode } from '$lib/enums';
+ import { ICON_CLASS_DEFAULT, PDF_AS_IMAGES_LEGACY_CONTENT_REGEX } from '$lib/constants';
+ import { AttachmentType, MimeTypeApplication, PdfViewMode } from '$lib/enums';
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { getLanguageFromFilename } from '$lib/utils';
- import { convertPDFToImage } from '$lib/utils/browser-only';
+ import { convertPDFToImage, convertPDFToText } from '$lib/utils/browser-only';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
@@ -23,54 +23,70 @@
let pdfViewMode = $state<PdfViewMode>(PdfViewMode.PAGES);
let pdfImages = $state<string[]>([]);
let pdfImagesLoading = $state(false);
+ // a failure stays on screen: the effect does not retry while an error is set
let pdfImagesError = $state<string | null>(null);
+ let pdfText = $state<string | null>(null);
+ let pdfTextLoading = $state(false);
+ let pdfTextError = $state<string | null>(null);
let language = $derived(getLanguageFromFilename(displayName));
+ // older PDFs sent as images stored a placeholder in `content`, so their text is extracted again
+ let hasStoredText = $derived(
+ currentItem?.attachment?.type === AttachmentType.PDF
+ ? !(
+ currentItem.attachment.processedAsImages &&
+ PDF_AS_IMAGES_LEGACY_CONTENT_REGEX.test(currentItem.attachment.content)
+ )
+ : !!displayTextContent
+ );
+
+ let isLoading = $derived(pdfImagesLoading || pdfTextLoading);
+
+ function getPdfFile(): File | null {
+ if (currentItem?.uploadedFile?.file) {
+ return currentItem.uploadedFile.file;
+ }
+
+ if (
+ currentItem?.attachment &&
+ 'base64Data' in currentItem.attachment &&
+ currentItem.attachment.base64Data
+ ) {
+ const byteArray = Uint8Array.from(atob(currentItem.attachment.base64Data), (char) =>
+ char.charCodeAt(0)
+ );
+
+ return new File([byteArray], displayName, { type: MimeTypeApplication.PDF });
+ }
+
+ return null;
+ }
+
async function loadPdfImages() {
if (pdfImages.length > 0 || pdfImagesLoading || !currentItem) return;
+ if (
+ currentItem.attachment?.type === AttachmentType.PDF &&
+ currentItem.attachment.images?.length
+ ) {
+ pdfImages = currentItem.attachment.images;
+
+ return;
+ }
+
+ if (pdfImagesError) return;
+
pdfImagesLoading = true;
- pdfImagesError = null;
try {
- let file: File | null = null;
-
- if (currentItem.uploadedFile?.file) {
- file = currentItem.uploadedFile.file;
- } else if (currentItem.attachment) {
- // Check if we have pre-processed images
- if (
- 'images' in currentItem.attachment &&
- currentItem.attachment.images &&
- Array.isArray(currentItem.attachment.images) &&
- currentItem.attachment.images.length > 0
- ) {
- pdfImages = currentItem.attachment.images;
-
- return;
- }
-
- // Convert base64 back to File for processing
- if ('base64Data' in currentItem.attachment && currentItem.attachment.base64Data) {
- const base64Data = currentItem.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' });
- }
- }
+ const file = getPdfFile();
- if (file) {
- pdfImages = await convertPDFToImage(file);
- } else {
+ if (!file) {
throw new Error('No PDF file available for conversion');
}
+
+ pdfImages = await convertPDFToImage(file);
} catch (error) {
pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
} finally {
@@ -78,26 +94,62 @@
}
}
+ async function loadPdfText() {
+ if (pdfText !== null || pdfTextLoading || !currentItem) return;
+
+ if (hasStoredText) {
+ pdfText = displayTextContent ?? '';
+
+ return;
+ }
+
+ if (pdfTextError) return;
+
+ pdfTextLoading = true;
+
+ try {
+ const file = getPdfFile();
+
+ if (!file) {
+ throw new Error('No PDF file available for text extraction');
+ }
+
+ pdfText = await convertPDFToText(file);
+ } catch (error) {
+ pdfTextError = error instanceof Error ? error.message : 'Failed to extract PDF text';
+ } finally {
+ pdfTextLoading = false;
+ }
+ }
+
$effect(() => {
if (pdfViewMode === PdfViewMode.PAGES) {
loadPdfImages();
+ } else {
+ loadPdfText();
}
});
</script>
<div class="mb-4 flex items-center justify-end gap-2">
<Button
- disabled={pdfImagesLoading}
+ disabled={isLoading}
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
size="sm"
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
>
- <FileText class="mr-1 {ICON_CLASS_DEFAULT}" />
+ {#if pdfTextLoading}
+ <div
+ class="mr-1 {ICON_CLASS_DEFAULT} animate-spin rounded-full border-2 border-current border-t-transparent"
+ ></div>
+ {:else}
+ <FileText class="mr-1 {ICON_CLASS_DEFAULT}" />
+ {/if}
Text
</Button>
<Button
- disabled={pdfImagesLoading}
+ disabled={isLoading}
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
size="sm"
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
@@ -136,28 +188,24 @@
</Alert.Root>
{/if}
-{#if pdfImagesLoading}
- <div class="flex flex-1 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-white border-t-transparent"
- ></div>
-
- <p class="text-white/70">Converting PDF to images...</p>
+{#if pdfViewMode === PdfViewMode.TEXT}
+ {#if pdfTextLoading}
+ {@render loadingStatus('Extracting PDF text...')}
+ {:else if pdfTextError}
+ {@render status('Failed to extract PDF text', pdfTextError)}
+ {:else if pdfText}
+ <div class="px-4 pb-4">
+ <SyntaxHighlightedCode class="max-w-4xl" code={pdfText} {language} maxHeight="none" />
</div>
- </div>
+ {:else}
+ {@render status('No text found in this PDF')}
+ {/if}
+{:else if pdfImagesLoading}
+ {@render loadingStatus('Converting PDF to images...')}
{:else if pdfImagesError}
- <div class="flex flex-1 items-center justify-center p-8">
- <div class="text-center">
- <FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
-
- <p class="mb-4 text-white/70">Failed to load PDF images</p>
-
- <p class="text-sm text-white/50">{pdfImagesError}</p>
- </div>
- </div>
+ {@render status('Failed to load PDF images', pdfImagesError)}
{:else if pdfImages.length > 0}
- {#each pdfImages as image, index (image)}
+ {#each pdfImages as image, index (index)}
<p class="mb-2 text-sm text-white/50">Page {index + 1}</p>
<img alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" src={image} />
@@ -165,22 +213,31 @@
<div class="h-4"></div>
{/each}
{:else}
+ {@render status('No PDF pages available')}
+{/if}
+
+{#snippet loadingStatus(message: string)}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
- <FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
+ <div
+ class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-white border-t-transparent"
+ ></div>
- <p class="text-white/70">No PDF pages available</p>
+ <p class="text-white/70">{message}</p>
</div>
</div>
-{/if}
+{/snippet}
-{#if pdfViewMode === PdfViewMode.TEXT && displayTextContent}
- <div class="px-4 pb-4">
- <SyntaxHighlightedCode
- class="max-w-4xl"
- code={displayTextContent}
- {language}
- maxHeight="none"
- />
+{#snippet status(message: string, detail?: string)}
+ <div class="flex flex-1 items-center justify-center p-8">
+ <div class="text-center">
+ <FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
+
+ <p class={['text-white/70', detail && 'mb-4']}>{message}</p>
+
+ {#if detail}
+ <p class="text-sm text-white/50">{detail}</p>
+ {/if}
+ </div>
</div>
-{/if}
+{/snippet}
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte
index e5ba09dba..e4ecd2357 100644
--- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte
@@ -10,6 +10,7 @@
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
+ isPdf: boolean;
preview?: string;
}
@@ -46,7 +47,7 @@
]}
onclick={() => onNavigate(index)}
>
- {#if item.isImage && item.preview}
+ {#if (item.isImage || item.isPdf) && item.preview}
<img alt={item.name} class="h-12 w-12 object-cover" src={item.preview} />
{:else}
<div
diff --git a/tools/ui/src/lib/constants/supported-file-types.constants.ts b/tools/ui/src/lib/constants/supported-file-types.constants.ts
index 5675d682f..255b762ff 100644
--- a/tools/ui/src/lib/constants/supported-file-types.constants.ts
+++ b/tools/ui/src/lib/constants/supported-file-types.constants.ts
@@ -74,6 +74,9 @@ export const IMAGE_FILE_TYPES = {
}
} as const;
+/** `content` that messages stored before the text was kept carry for a PDF sent as images. */
+export const PDF_AS_IMAGES_LEGACY_CONTENT_REGEX = /^PDF file with \d+ pages$/;
+
export const PDF_FILE_TYPES = {
[FileTypePdf.PDF]: {
extensions: [FileExtensionPdf.PDF],
diff --git a/tools/ui/src/lib/utils/attachment-display.ts b/tools/ui/src/lib/utils/attachment-display.ts
index 0ec7cf40d..08d7bb34c 100644
--- a/tools/ui/src/lib/utils/attachment-display.ts
+++ b/tools/ui/src/lib/utils/attachment-display.ts
@@ -71,13 +71,22 @@ export function getAttachmentDisplayItems(
for (const [index, attachment] of attachments.entries()) {
const isImage = isImageFile(attachment);
+ let preview: string | undefined;
+
+ if (isImage && 'base64Url' in attachment) {
+ preview = attachment.base64Url;
+ } else if (attachment.type === AttachmentType.PDF && attachment.processedAsImages) {
+ // first rendered page is the thumbnail of a PDF sent as images
+ preview = attachment.images?.[0];
+ }
+
items.push({
attachment,
attachmentIndex: index,
id: `attachment-${index}`,
isImage,
name: attachment.name,
- preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
+ preview,
size: 'size' in attachment ? attachment.size : undefined,
textContent: 'content' in attachment ? attachment.content : undefined
});
diff --git a/tools/ui/src/lib/utils/convert-files-to-extra.ts b/tools/ui/src/lib/utils/convert-files-to-extra.ts
index 735e91c44..3216de8b9 100644
--- a/tools/ui/src/lib/utils/convert-files-to-extra.ts
+++ b/tools/ui/src/lib/utils/convert-files-to-extra.ts
@@ -140,6 +140,8 @@ export async function parseFilesToMessageExtras(
// Process PDF as images (only for vision models)
try {
const images = await convertPDFToImage(file.file);
+ // kept for the preview's Text view; the model only receives the images
+ const content = file.textContent ?? (await convertPDFToText(file.file).catch(() => ''));
// Show success toast for PDF image processing
toast.success(
@@ -151,7 +153,7 @@ export async function parseFilesToMessageExtras(
extras.push({
base64Data: base64Data,
- content: `PDF file with ${images.length} pages`,
+ content,
images: images,
name: file.name,
processedAsImages: true,
diff --git a/tools/ui/tests/stories/chat/screen/attachments/ChatAttachmentsPreview.stories.svelte b/tools/ui/tests/stories/chat/screen/attachments/ChatAttachmentsPreview.stories.svelte
new file mode 100644
index 000000000..f7f828f33
--- /dev/null
+++ b/tools/ui/tests/stories/chat/screen/attachments/ChatAttachmentsPreview.stories.svelte
@@ -0,0 +1,47 @@
+<script lang="ts" module>
+ import { defineMeta } from '@storybook/addon-svelte-csf';
+ import ChatAttachmentsPreview from '$lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte';
+ import { AttachmentType } from '$lib/enums';
+ import { expect, waitFor } from 'storybook/test';
+
+ const { Story } = defineMeta({
+ component: ChatAttachmentsPreview,
+ parameters: {
+ layout: 'fullscreen'
+ },
+ title: 'Components/ChatScreen/ChatAttachments/ChatAttachmentsPreview'
+ });
+
+ // same backdrop as DialogChatAttachmentsPreview
+ const frameClass = 'flex h-screen flex-col bg-neutral-950';
+
+ // valid base64 that is not a PDF: pdf.js throws InvalidPDFException
+ const brokenPdfAttachment: DatabaseMessageExtraPdfFile = {
+ base64Data: btoa('this is not a pdf'),
+ content: 'Extracted text of the broken PDF.',
+ name: 'broken.pdf',
+ processedAsImages: false,
+ size: 17,
+ type: AttachmentType.PDF
+ };
+</script>
+
+<!-- conversion fails once: shows the error and does not retry -->
+<Story
+ asChild
+ name="PdfConversionError"
+ play={async ({ canvas }) => {
+ await waitFor(() => expect(canvas.getByText('Failed to load PDF images')).toBeInTheDocument(), {
+ timeout: 5000
+ });
+
+ // a retry loop would flip back to the loading state
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ await expect(canvas.queryByText('Converting PDF to images...')).not.toBeInTheDocument();
+ await expect(canvas.getByText('Failed to load PDF images')).toBeInTheDocument();
+ }}
+>
+ <div class={frameClass}>
+ <ChatAttachmentsPreview attachments={[brokenPdfAttachment]} class="min-h-0 flex-1" />
+ </div>
+</Story>