RAG preview: render PDFs in the browser-native viewer + cited-excerpt panel

This commit is contained in:
Roland Tannous 2026-06-03 12:04:41 +04:00
commit e89713aaed
4 changed files with 188 additions and 3 deletions

View file

@ -0,0 +1,85 @@
import type { PreviewTarget } from "@/features/rag/api/rag-api";
import { PreviewPdfNativeView } from "@/features/rag/components/preview-pdf-native";
import { render, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
function makeTarget(overrides: Partial<PreviewTarget> = {}): PreviewTarget {
return {
documentId: "doc-1",
filename: "report.pdf",
contentType: "application/pdf",
mediaKind: "pdf",
byteSize: 100,
status: "completed",
kbId: "kb-1",
threadId: null,
chunkId: "c1",
chunkIndex: 0,
targetPage: 3,
// pageCharStart/End span the second line of the snippet below.
snippet: "Intro line\nThe cited sentence here\nOutro",
kind: "text",
sourcePageIndex: 2,
pageCharStart: 11,
pageCharEnd: 34,
lineStart: null,
lineEnd: null,
pdfRegions: [],
...overrides,
};
}
describe("PreviewPdfNativeView", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a native PDF iframe jumped to the cited page from a signed URL", () => {
render(
React.createElement(PreviewPdfNativeView, {
target: makeTarget(),
file: "/api/rag/documents/doc-1/file-signed?token=abc",
}),
);
const iframe = document.querySelector("iframe");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("src")).toBe(
"/api/rag/documents/doc-1/file-signed?token=abc#page=3&view=FitH",
);
});
it("highlights the cited excerpt beside the viewer", () => {
render(
React.createElement(PreviewPdfNativeView, {
target: makeTarget(),
file: "/signed?token=x",
}),
);
const mark = screen.getByText("The cited sentence here");
expect(mark.tagName).toBe("MARK");
});
it("uses an object URL for a Blob and revokes it on unmount", () => {
const createSpy = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:obj");
const revokeSpy = vi
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => undefined);
const { unmount } = render(
React.createElement(PreviewPdfNativeView, {
target: makeTarget({ targetPage: 2 }),
file: new Blob(["%PDF-1.4"], { type: "application/pdf" }),
}),
);
const iframe = document.querySelector("iframe");
expect(iframe?.getAttribute("src")).toBe("blob:obj#page=2&view=FitH");
expect(createSpy).toHaveBeenCalled();
unmount();
expect(revokeSpy).toHaveBeenCalledWith("blob:obj");
});
});

View file

@ -22,7 +22,7 @@ import {
isInlineBlobAllowed,
usePreviewStore,
} from "../stores/preview-store";
import { PreviewPdfView } from "./preview-pdf-view";
import { PreviewPdfNativeView } from "./preview-pdf-native";
import { PreviewTextView } from "./preview-text-view";
import { PreviewUnavailable } from "./preview-unavailable";
@ -107,7 +107,7 @@ function renderPreviewBody({
pdfFile &&
isInlineBlobAllowed(target.mediaKind)
) {
return <PreviewPdfView target={target} file={pdfFile} />;
return <PreviewPdfNativeView target={target} file={pdfFile} />;
}
// text/image/docx/html/unknown all route through text-view: text

View file

@ -0,0 +1,100 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { ExternalLinkIcon } from "lucide-react";
import { type FC, useEffect, useMemo } from "react";
import type { PreviewTarget } from "../api/rag-api";
import { renderHighlightedSnippet } from "./preview-text-view";
interface PreviewPdfNativeViewProps {
target: PreviewTarget;
/** Signed file URL (preferred — supports HTTP Range) or a Blob of bytes. */
file: string | Blob;
}
/** Lightweight PDF preview: render the document in the browser's built-in
* PDF viewer via an <iframe> jumped to the cited page (`#page=N`). A
* companion strip shows the cited excerpt highlighted, since the native
* viewer can't be overlaid with on-page region boxes. This deliberately
* avoids bundling a JS PDF renderer (react-pdf/pdfjs). The file response is
* served same-origin with `frame-ancestors` allowing this iframe (see
* routes/rag.py `_serve_document_file_row` + main.py `_is_frameable_path`). */
export const PreviewPdfNativeView: FC<PreviewPdfNativeViewProps> = ({
target,
file,
}) => {
// A Blob needs an object URL; a signed URL string is used directly.
const objectUrl = useMemo(
() => (typeof file === "string" ? null : URL.createObjectURL(file)),
[file],
);
useEffect(() => {
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [objectUrl]);
const baseUrl = typeof file === "string" ? file : objectUrl;
const page = target.targetPage ?? 1;
// #page=N + view=FitH are honored by the Chrome/Firefox/Edge native PDF
// viewers; the hash is never sent to the server.
const src = baseUrl ? `${baseUrl}#page=${page}&view=FitH` : null;
const snippet = target.snippet;
const hasSnippet = snippet !== null && snippet.trim().length > 0;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
<span className="truncate text-sm font-medium" title={target.filename}>
{target.filename}
</span>
{target.targetPage != null ? (
<span className="shrink-0 text-xs text-muted-foreground">
Page {target.targetPage}
</span>
) : null}
</div>
{src ? (
<iframe
src={src}
title={`PDF preview: ${target.filename}`}
className="min-h-0 w-full flex-1 border-0 bg-muted/20"
/>
) : (
<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
Preview unavailable.
</div>
)}
{hasSnippet ? (
<details
open={true}
className="max-h-40 shrink-0 overflow-auto border-t border-border/60 bg-muted/30 px-3 py-2"
>
<summary className="cursor-pointer text-[10px] uppercase tracking-wider text-muted-foreground">
Cited excerpt
</summary>
<pre className="mt-1.5 whitespace-pre-wrap break-words text-xs leading-relaxed text-foreground/85">
{renderHighlightedSnippet(snippet, target)}
</pre>
</details>
) : null}
{src ? (
<div className="shrink-0 border-t border-border/60 px-3 py-2">
<Button asChild={true} variant="outline" size="sm" className="w-full">
<a href={src} target="_blank" rel="noopener noreferrer">
<ExternalLinkIcon className="size-3.5" />
Open in new tab
</a>
</Button>
</div>
) : null}
</div>
);
};

View file

@ -159,7 +159,7 @@ function findFuzzyMatch(
);
}
const renderHighlightedSnippet = (
export const renderHighlightedSnippet = (
snippet: string,
target: PreviewTarget,
): ReactNode => {