diff --git a/studio/frontend/src/__tests__/preview-pdf-native.test.tsx b/studio/frontend/src/__tests__/preview-pdf-native.test.tsx new file mode 100644 index 0000000000..8899e36e7e --- /dev/null +++ b/studio/frontend/src/__tests__/preview-pdf-native.test.tsx @@ -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 { + 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"); + }); +}); diff --git a/studio/frontend/src/features/rag/components/preview-panel.tsx b/studio/frontend/src/features/rag/components/preview-panel.tsx index 9a576c229b..0b418932f4 100644 --- a/studio/frontend/src/features/rag/components/preview-panel.tsx +++ b/studio/frontend/src/features/rag/components/preview-panel.tsx @@ -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 ; + return ; } // text/image/docx/html/unknown all route through text-view: text diff --git a/studio/frontend/src/features/rag/components/preview-pdf-native.tsx b/studio/frontend/src/features/rag/components/preview-pdf-native.tsx new file mode 100644 index 0000000000..d887852496 --- /dev/null +++ b/studio/frontend/src/features/rag/components/preview-pdf-native.tsx @@ -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