RAG preview: render PDFs in the browser-native viewer + cited-excerpt panel
This commit is contained in:
parent
0930e08496
commit
e89713aaed
4 changed files with 188 additions and 3 deletions
85
studio/frontend/src/__tests__/preview-pdf-native.test.tsx
Normal file
85
studio/frontend/src/__tests__/preview-pdf-native.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -159,7 +159,7 @@ function findFuzzyMatch(
|
|||
);
|
||||
}
|
||||
|
||||
const renderHighlightedSnippet = (
|
||||
export const renderHighlightedSnippet = (
|
||||
snippet: string,
|
||||
target: PreviewTarget,
|
||||
): ReactNode => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue