From 11931bcff1c2f28b3bc2cdb566b97652590d9d0d Mon Sep 17 00:00:00 2001 From: Emil Lysgaard Date: Thu, 4 Dec 2025 15:17:47 +0100 Subject: [PATCH 01/11] Add in-app PDF preview for Unity Catalog citations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add react-pdf for PDF rendering in a side drawer - Create backend proxy (/api/files/volumes) for authenticated PDF fetching - Parse UC PDF links and extract page/text fragment from URL hash - Show filename, page number, and cited text in tooltip - Include download and "Open in Catalog" buttons in preview header 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- e2e-chatbot-app-next/client/package.json | 1 + .../databricks-message-citation.tsx | 15 + .../pdf-preview/PDFCitationLink.tsx | 61 ++++ .../pdf-preview/PDFPreviewSheet.tsx | 157 ++++++++++ .../src/components/pdf-preview/PDFViewer.tsx | 130 ++++++++ .../src/components/pdf-preview/index.ts | 8 + .../client/src/components/ui/sheet.tsx | 17 +- .../client/src/lib/pdf-utils.ts | 107 +++++++ e2e-chatbot-app-next/client/vite.config.ts | 4 +- e2e-chatbot-app-next/package-lock.json | 277 ++++++++++++++++++ e2e-chatbot-app-next/server/src/index.ts | 8 +- .../server/src/routes/files.ts | 184 ++++++++++++ .../tests/e2e/chat-resume.test.ts | 2 +- 13 files changed, 964 insertions(+), 7 deletions(-) create mode 100644 e2e-chatbot-app-next/client/src/components/pdf-preview/PDFCitationLink.tsx create mode 100644 e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx create mode 100644 e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx create mode 100644 e2e-chatbot-app-next/client/src/components/pdf-preview/index.ts create mode 100644 e2e-chatbot-app-next/client/src/lib/pdf-utils.ts create mode 100644 e2e-chatbot-app-next/server/src/routes/files.ts diff --git a/e2e-chatbot-app-next/client/package.json b/e2e-chatbot-app-next/client/package.json index b3de6609..774f62f3 100644 --- a/e2e-chatbot-app-next/client/package.json +++ b/e2e-chatbot-app-next/client/package.json @@ -40,6 +40,7 @@ "next-themes": "^0.4.6", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-pdf": "^10.2.0", "react-router-dom": "^6.22.0", "react-syntax-highlighter": "^15.6.6", "sonner": "^1.5.0", diff --git a/e2e-chatbot-app-next/client/src/components/databricks-message-citation.tsx b/e2e-chatbot-app-next/client/src/components/databricks-message-citation.tsx index a4093510..a5bb3b04 100644 --- a/e2e-chatbot-app-next/client/src/components/databricks-message-citation.tsx +++ b/e2e-chatbot-app-next/client/src/components/databricks-message-citation.tsx @@ -6,6 +6,8 @@ import type { } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { cn } from '@/lib/utils'; +import { parseUnityCatalogPDFLink } from '@/lib/pdf-utils'; +import { PDFCitationLink } from './pdf-preview'; /** * ReactMarkdown/Streamdown component that handles Databricks message citations. @@ -50,11 +52,24 @@ const isDatabricksMessageCitationLink = ( link?.endsWith('::databricks_citation') ?? false; // Renders the Databricks message citation. +// UC PDF links open in a preview drawer, other links open in a new tab. const DatabricksMessageCitationRenderer = ( props: PropsWithChildren<{ href: string; }>, ) => { + // Check if this is a Unity Catalog PDF link + const pdfMetadata = parseUnityCatalogPDFLink(props.href); + + if (pdfMetadata) { + return ( + + {props.children} + + ); + } + + // Default behavior: open in new tab with tooltip return ( diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFCitationLink.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFCitationLink.tsx new file mode 100644 index 00000000..db155f08 --- /dev/null +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFCitationLink.tsx @@ -0,0 +1,61 @@ +import { useState, useCallback, type ReactNode } from 'react'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { PDFPreviewSheet } from './PDFPreviewSheet'; +import type { UCPDFMetadata } from '@/lib/pdf-utils'; + +export interface PDFCitationLinkProps { + children: ReactNode; + pdfMetadata: UCPDFMetadata; +} + +export function PDFCitationLink({ children, pdfMetadata }: PDFCitationLinkProps) { + const [isOpen, setIsOpen] = useState(false); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsOpen(true); + }, []); + + return ( + <> + + + + + +
+
{pdfMetadata.filename}
+ {pdfMetadata.page && ( +
+ Page {pdfMetadata.page} +
+ )} + {pdfMetadata.textFragment && ( +
+ "{pdfMetadata.textFragment}" +
+ )} +
+
+
+ + + + ); +} diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx new file mode 100644 index 00000000..e200f971 --- /dev/null +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx @@ -0,0 +1,157 @@ +import { useState, useCallback } from 'react'; +import { Download, ExternalLink, FileWarning, Lock, AlertCircle } from 'lucide-react'; + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { PDFViewer, type PDFError } from './PDFViewer'; +import { getUnityCatalogExplorerUrl } from '@/lib/pdf-utils'; + +export interface PDFPreviewSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + filename: string; + volumePath: string; + downloadUrl: string; + initialPage?: number; +} + +function PDFErrorState({ + error, + filename, + onRetry, +}: { + error: PDFError; + filename: string; + onRetry: () => void; +}) { + const getErrorContent = () => { + switch (error.type) { + case 'NotFoundError': + return { + icon: , + title: 'File not found', + description: `We could not find the file "${filename}". Please check if the file was moved, renamed, or deleted.`, + }; + case 'PermissionError': + return { + icon: , + title: "You can't access this file", + description: `You do not have permission to access "${filename}". Please contact an administrator.`, + }; + case 'LoadError': + default: + return { + icon: , + title: 'Failed to load PDF', + description: + error.type === 'LoadError' && error.message + ? error.message + : 'An unexpected error occurred while loading the PDF file.', + }; + } + }; + + const { icon, title, description } = getErrorContent(); + + return ( +
+ {icon} +
+

{title}

+

{description}

+
+ +
+ ); +} + +export function PDFPreviewSheet({ + open, + onOpenChange, + filename, + volumePath, + downloadUrl, + initialPage, +}: PDFPreviewSheetProps) { + const [error, setError] = useState(null); + const [retryKey, setRetryKey] = useState(0); + + const ucExplorerUrl = getUnityCatalogExplorerUrl(volumePath, filename); + + const handleLoadError = useCallback((err: PDFError) => { + setError(err); + }, []); + + const handleRetry = useCallback(() => { + setError(null); + setRetryKey((prev) => prev + 1); + }, []); + + // Reset error state when sheet closes + const handleOpenChange = useCallback( + (isOpen: boolean) => { + if (!isOpen) { + setError(null); + } + onOpenChange(isOpen); + }, + [onOpenChange], + ); + + return ( + + + + {filename} +
+ {ucExplorerUrl && ( + + )} + +
+
+ +
+ {error ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx new file mode 100644 index 00000000..9bfa1c57 --- /dev/null +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx @@ -0,0 +1,130 @@ +import { useState, useCallback } from 'react'; +import { Document, Page, pdfjs } from 'react-pdf'; +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import 'react-pdf/dist/Page/TextLayer.css'; + +import { Loader } from '@/components/elements/loader'; +import { Button } from '@/components/ui/button'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +// Configure PDF.js worker +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, +).toString(); + +export type PDFError = + | { type: 'NotFoundError' } + | { type: 'PermissionError' } + | { type: 'LoadError'; message?: string }; + +export interface PDFViewerProps { + url: string; + initialPage?: number; + onLoadError?: (error: PDFError) => void; +} + +export function PDFViewer({ url, initialPage = 1, onLoadError }: PDFViewerProps) { + const [numPages, setNumPages] = useState(null); + const [pageNumber, setPageNumber] = useState(initialPage); + const [isLoading, setIsLoading] = useState(true); + + const onDocumentLoadSuccess = useCallback( + ({ numPages }: { numPages: number }) => { + setNumPages(numPages); + setIsLoading(false); + // Ensure initial page is within bounds + if (initialPage > numPages) { + setPageNumber(1); + } + }, + [initialPage], + ); + + const onDocumentLoadError = useCallback( + (error: Error) => { + setIsLoading(false); + // Map error to our error type + const errorMessage = error.message?.toLowerCase() || ''; + if (errorMessage.includes('404') || errorMessage.includes('not found')) { + onLoadError?.({ type: 'NotFoundError' }); + } else if ( + errorMessage.includes('403') || + errorMessage.includes('forbidden') || + errorMessage.includes('permission') + ) { + onLoadError?.({ type: 'PermissionError' }); + } else { + onLoadError?.({ type: 'LoadError', message: error.message }); + } + }, + [onLoadError], + ); + + const goToPrevPage = useCallback(() => { + setPageNumber((prev) => Math.max(prev - 1, 1)); + }, []); + + const goToNextPage = useCallback(() => { + setPageNumber((prev) => Math.min(prev + 1, numPages || prev)); + }, [numPages]); + + return ( +
+ {/* Page navigation */} + {numPages && numPages > 1 && ( +
+ + + Page {pageNumber} of {numPages} + + +
+ )} + + {/* PDF content */} +
+ + +
+ } + > + {!isLoading && ( + + +
+ } + /> + )} + + + + ); +} diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/index.ts b/e2e-chatbot-app-next/client/src/components/pdf-preview/index.ts new file mode 100644 index 00000000..3c24eaba --- /dev/null +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/index.ts @@ -0,0 +1,8 @@ +export { PDFCitationLink } from './PDFCitationLink'; +export type { PDFCitationLinkProps } from './PDFCitationLink'; + +export { PDFPreviewSheet } from './PDFPreviewSheet'; +export type { PDFPreviewSheetProps } from './PDFPreviewSheet'; + +export { PDFViewer } from './PDFViewer'; +export type { PDFViewerProps, PDFError } from './PDFViewer'; diff --git a/e2e-chatbot-app-next/client/src/components/ui/sheet.tsx b/e2e-chatbot-app-next/client/src/components/ui/sheet.tsx index 951b3289..4de77039 100644 --- a/e2e-chatbot-app-next/client/src/components/ui/sheet.tsx +++ b/e2e-chatbot-app-next/client/src/components/ui/sheet.tsx @@ -7,6 +7,10 @@ import { cn } from '@/lib/utils'; const Sheet = SheetPrimitive.Root; +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + const SheetPortal = SheetPrimitive.Portal; const SheetOverlay = React.forwardRef< @@ -120,4 +124,15 @@ const SheetDescription = React.forwardRef< )); SheetDescription.displayName = SheetPrimitive.Description.displayName; -export { Sheet, SheetContent, SheetTitle }; +export { + Sheet, + SheetTrigger, + SheetClose, + SheetPortal, + SheetOverlay, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts b/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts new file mode 100644 index 00000000..79a297b8 --- /dev/null +++ b/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts @@ -0,0 +1,107 @@ +/** + * Utilities for parsing and handling Unity Catalog PDF links. + */ + +export interface UCPDFMetadata { + filename: string; + volumePath: string; + page?: number; + textFragment?: string; + /** URL to fetch/download the PDF through our backend proxy */ + downloadUrl: string; + /** Original Databricks URL (for reference/debugging) */ + originalUrl: string; +} + +/** + * Parse a Unity Catalog PDF link and extract metadata. + * + * Expected URL format: /ajax-api/2.0/fs/files/Volumes/{catalog}/{schema}/{volume}/{filename}.pdf + * Optional hash: #page=N:~:text=encoded_text or #:~:text=encoded_text or #page=N + */ +export function parseUnityCatalogPDFLink(href: string): UCPDFMetadata | null { + try { + const urlObject = new URL(href, window.location.origin); + const pathname = decodeURIComponent(urlObject.pathname); + + const regex = + /^\/ajax-api\/2\.0\/fs\/files\/(Volumes\/[^/]+\/[^/]+\/[^/]+)\/(.+\.pdf)$/i; + const match = pathname.match(regex); + + if (match) { + let page: number | undefined = undefined; + let textFragment: string | undefined = undefined; + + // Check if page is in query params (legacy format) + const pageParam = urlObject.searchParams.get('page'); + if (pageParam) { + const parsedPage = Number.parseInt(pageParam, 10); + page = !Number.isNaN(parsedPage) ? parsedPage : undefined; + } + + // Check if hash contains page and/or text fragment (new format) + // Hash can be: #page=5:~:text=cited%20text or just #:~:text=cited%20text or #page=5 + if (urlObject.hash) { + const hash = urlObject.hash.substring(1); // Remove the '#' prefix + + // Check for page in hash (format: page=5:~:text=... or page=5) + const hashPageMatch = hash.match(/^page=(\d+)/); + if (hashPageMatch) { + const parsedPage = Number.parseInt(hashPageMatch[1], 10); + page = !Number.isNaN(parsedPage) ? parsedPage : page; + } + + // Check for text fragment in hash (format: :~:text=...) + const textFragmentMatch = hash.match(/:~:text=(.+)$/); + if (textFragmentMatch) { + textFragment = decodeURIComponent(textFragmentMatch[1]); + } else if (hash.startsWith(':~:text=')) { + // Handle case where hash only contains text fragment without page + textFragment = decodeURIComponent(hash.substring(':~:text='.length)); + } + } + + // Create the proxy URL that goes through our backend + // The backend will authenticate with Databricks and fetch the file + const volumePathWithFilename = `${match[1]}/${match[2]}`; + const proxyUrl = `/api/files/volumes/${volumePathWithFilename}`; + + // Keep the original URL for reference (e.g., for "Open in Catalog" link) + const originalUrl = new URL(href, window.location.origin); + originalUrl.searchParams.delete('page'); + + return { + volumePath: match[1], + filename: match[2], + page, + textFragment, + downloadUrl: proxyUrl, + originalUrl: originalUrl.toString(), + }; + } + } catch { + return null; + } + return null; +} + +/** + * Check if a URL is a Unity Catalog PDF link. + */ +export function isUnityCatalogPDFLink(href: string): boolean { + return parseUnityCatalogPDFLink(href) !== null; +} + +/** + * Generate Unity Catalog explorer URL for a file. + */ +export function getUnityCatalogExplorerUrl( + volumePath: string, + filename: string, +): string { + if (!filename || !volumePath) { + return ''; + } + // UC expects the filePreviewPath to be encoded + return `/explore/data/${volumePath}?filePreviewPath=${encodeURIComponent(filename)}`; +} diff --git a/e2e-chatbot-app-next/client/vite.config.ts b/e2e-chatbot-app-next/client/vite.config.ts index 40d84747..b584314b 100644 --- a/e2e-chatbot-app-next/client/vite.config.ts +++ b/e2e-chatbot-app-next/client/vite.config.ts @@ -11,10 +11,10 @@ export default defineConfig({ }, }, server: { - port: 3000, + port: 4000, proxy: { '/api': { - target: 'http://localhost:3001', + target: 'http://localhost:4001', changeOrigin: true, }, }, diff --git a/e2e-chatbot-app-next/package-lock.json b/e2e-chatbot-app-next/package-lock.json index 30d5f2e5..93ffa8b7 100644 --- a/e2e-chatbot-app-next/package-lock.json +++ b/e2e-chatbot-app-next/package-lock.json @@ -57,6 +57,7 @@ "next-themes": "^0.4.6", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-pdf": "^10.2.0", "react-router-dom": "^6.22.0", "react-syntax-highlighter": "^15.6.6", "sonner": "^1.5.0", @@ -1888,6 +1889,191 @@ "node": ">=18" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.83.tgz", + "integrity": "sha512-f9GVB9VNc9vn/nroc9epXRNkVpvNPZh69+qzLJIm9DfruxFqX0/jsXG46OGWAJgkO4mN0HvFHjRROMXKVmPszg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.83", + "@napi-rs/canvas-darwin-arm64": "0.1.83", + "@napi-rs/canvas-darwin-x64": "0.1.83", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.83", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.83", + "@napi-rs/canvas-linux-arm64-musl": "0.1.83", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.83", + "@napi-rs/canvas-linux-x64-gnu": "0.1.83", + "@napi-rs/canvas-linux-x64-musl": "0.1.83", + "@napi-rs/canvas-win32-x64-msvc": "0.1.83" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.83.tgz", + "integrity": "sha512-TbKM2fh9zXjqFIU8bgMfzG7rkrIYdLKMafgPhFoPwKrpWk1glGbWP7LEu8Y/WrMDqTGFdRqUmuX89yQEzZbkiw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.83.tgz", + "integrity": "sha512-gp8IDVUloPUmkepHly4xRUOfUJSFNvA4jR7ZRF5nk3YcGzegSFGeICiT4PnYyPgSKEhYAFe1Y2XNy0Mp6Tu8mQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.83.tgz", + "integrity": "sha512-r4ZJxiP9OgUbdGZhPDEXD3hQ0aIPcVaywtcTXvamYxTU/SWKAbKVhFNTtpRe1J30oQ25gWyxTkUKSBgUkNzdnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.83.tgz", + "integrity": "sha512-Uc6aSB05qH1r+9GUDxIE6F5ZF7L0nTFyyzq8ublWUZhw8fEGK8iy931ff1ByGFT04+xHJad1kBcL4R1ZEV8z7Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.83.tgz", + "integrity": "sha512-eEeaJA7V5KOFq7W0GtoRVbd3ak8UZpK+XLkCgUiFGtlunNw+ZZW9Cr/92MXflGe7o3SqqMUg+f975LPxO/vsOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.83.tgz", + "integrity": "sha512-cAvonp5XpbatVGegF9lMQNchs3z5RH6EtamRVnQvtoRtwbzOMcdzwuLBqDBQxQF79MFbuZNkWj3YRJjZCjHVzw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.83.tgz", + "integrity": "sha512-WFUPQ9qZy31vmLxIJ3MfmHw+R2g/mLCgk8zmh7maJW8snV3vLPA7pZfIS65Dc61EVDp1vaBskwQ2RqPPzwkaew==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.83.tgz", + "integrity": "sha512-X9YwIjsuy50WwOyYeNhEHjKHO8rrfH9M4U8vNqLuGmqsZdKua/GrUhdQGdjq7lTgdY3g4+Ta5jF8MzAa7UAs/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.83.tgz", + "integrity": "sha512-Vv2pLWQS8EnlSM1bstJ7vVhKA+mL4+my4sKUIn/bgIxB5O90dqiDhQjUDLP+5xn9ZMestRWDt3tdQEkGAmzq/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.83.tgz", + "integrity": "sha512-K1TtjbScfRNYhq8dengLLufXGbtEtWdUXPV505uLFPovyGHzDUGXLFP/zUJzj6xWXwgUjHNLgEPIt7mye0zr6Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", @@ -6935,6 +7121,24 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -7276,6 +7480,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/mermaid": { "version": "11.12.1", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.1.tgz", @@ -8194,6 +8415,18 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8484,6 +8717,35 @@ "react": ">=18" } }, + "node_modules/react-pdf": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.2.0.tgz", + "integrity": "sha512-zk0DIL31oCh8cuQycM0SJKfwh4Onz0/Nwi6wTOjgtEjWGUY6eM+/vuzvOP3j70qtEULn7m1JtaeGzud1w5fY2Q==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -9594,6 +9856,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -10597,6 +10865,15 @@ "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", "license": "MIT" }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", diff --git a/e2e-chatbot-app-next/server/src/index.ts b/e2e-chatbot-app-next/server/src/index.ts index c132da1c..9790f349 100644 --- a/e2e-chatbot-app-next/server/src/index.ts +++ b/e2e-chatbot-app-next/server/src/index.ts @@ -16,6 +16,7 @@ import { historyRouter } from './routes/history'; import { sessionRouter } from './routes/session'; import { messagesRouter } from './routes/messages'; import { configRouter } from './routes/config'; +import { filesRouter } from './routes/files'; import { ChatSDKError } from '@chat-template/core/errors'; // ESM-compatible __dirname @@ -24,17 +25,17 @@ const __dirname = dirname(__filename); const app: Express = express(); const isDevelopment = process.env.NODE_ENV !== 'production'; -// Either let PORT be set by env or use 3001 for development and 3000 for production +// Either let PORT be set by env or use 4001 for development and 4000 for production // The CHAT_APP_PORT can be used to override the port for the chat app. const PORT = process.env.CHAT_APP_PORT || process.env.PORT || - (isDevelopment ? 3001 : 3000); + (isDevelopment ? 4001 : 4000); // CORS configuration app.use( cors({ - origin: isDevelopment ? 'http://localhost:3000' : true, + origin: isDevelopment ? 'http://localhost:4000' : true, credentials: true, }), ); @@ -54,6 +55,7 @@ app.use('/api/history', historyRouter); app.use('/api/session', sessionRouter); app.use('/api/messages', messagesRouter); app.use('/api/config', configRouter); +app.use('/api/files', filesRouter); // Serve static files in production if (!isDevelopment) { diff --git a/e2e-chatbot-app-next/server/src/routes/files.ts b/e2e-chatbot-app-next/server/src/routes/files.ts new file mode 100644 index 00000000..9bca284e --- /dev/null +++ b/e2e-chatbot-app-next/server/src/routes/files.ts @@ -0,0 +1,184 @@ +import { + Router, + type Request, + type Response, + type Router as RouterType, +} from 'express'; +import { Readable } from 'node:stream'; +import { authMiddleware, requireAuth } from '../middleware/auth'; +import { getDatabricksToken, getCachedCliHost } from '@chat-template/auth'; +import { getHostUrl } from '@chat-template/utils'; + +export const filesRouter: RouterType = Router(); + +// Apply auth middleware to all file routes +filesRouter.use(authMiddleware); + +/** + * GET /api/files/volumes/* - Proxy Unity Catalog volume files through the backend + * + * This endpoint proxies requests to Databricks Files API with proper authentication. + * The frontend can request files without needing direct Databricks credentials. + * + * Example: GET /api/files/volumes/catalog/schema/volume/path/to/file.pdf + * Proxies to: https://{databricks-host}/ajax-api/2.0/fs/files/Volumes/catalog/schema/volume/path/to/file.pdf + */ +filesRouter.get( + '/volumes/{*path}', + requireAuth, + async (req: Request, res: Response) => { + try { + // Extract the full path after /volumes/ + // In Express 5 with path-to-regexp v8, {*path} captures all segments as an array + const pathParam = req.params.path; + const volumePath = Array.isArray(pathParam) + ? pathParam.join('/') + : pathParam; + + if (!volumePath) { + return res.status(400).json({ error: 'Volume path is required' }); + } + + // Get Databricks host - prefer cached CLI host, fall back to env + let hostUrl = getCachedCliHost(); + if (!hostUrl) { + hostUrl = getHostUrl(); + } + + // Construct the Databricks Files API URL + // Note: volumePath already includes "Volumes/catalog/schema/volume/filename" + const databricksUrl = `${hostUrl}/ajax-api/2.0/fs/files/${volumePath}`; + + console.log(`[Files Proxy] Fetching: ${databricksUrl}`); + + // Get authentication token + const token = await getDatabricksToken(); + + // Fetch the file from Databricks + const response = await fetch(databricksUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + redirect: 'follow', + }); + + if (!response.ok) { + console.error( + `[Files Proxy] Databricks API error: ${response.status} ${response.statusText}`, + ); + + // Map Databricks errors to appropriate HTTP responses + if (response.status === 404) { + return res.status(404).json({ error: 'File not found' }); + } + if (response.status === 403) { + return res.status(403).json({ error: 'Permission denied' }); + } + + return res.status(response.status).json({ + error: `Failed to fetch file: ${response.statusText}`, + }); + } + + // Get content type from response or infer from file extension + const contentType = + response.headers.get('content-type') || inferContentType(volumePath); + + // Set response headers + res.setHeader('Content-Type', contentType); + + // Forward content-length if available + const contentLength = response.headers.get('content-length'); + if (contentLength) { + res.setHeader('Content-Length', contentLength); + } + + // Forward content-disposition if available (for downloads) + const contentDisposition = response.headers.get('content-disposition'); + if (contentDisposition) { + res.setHeader('Content-Disposition', contentDisposition); + } + + // Stream the response body to the client + if (response.body) { + const reader = response.body.getReader(); + + const stream = new ReadableStream({ + async start(controller) { + while (true) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + break; + } + controller.enqueue(value); + } + }, + }); + + // Convert ReadableStream to Node.js readable and pipe to response + const nodeReadable = readableStreamToNodeReadable(stream); + nodeReadable.pipe(res); + } else { + // Fallback: read entire body as buffer + const buffer = await response.arrayBuffer(); + res.send(Buffer.from(buffer)); + } + } catch (error) { + console.error('[Files Proxy] Error:', error); + + if (error instanceof Error) { + return res.status(500).json({ + error: 'Failed to fetch file', + message: error.message, + }); + } + + return res.status(500).json({ error: 'Failed to fetch file' }); + } + }, +); + +/** + * Infer content type from file extension + */ +function inferContentType(path: string): string { + const ext = path.split('.').pop()?.toLowerCase(); + + const mimeTypes: Record = { + pdf: 'application/pdf', + json: 'application/json', + txt: 'text/plain', + csv: 'text/csv', + html: 'text/html', + xml: 'application/xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg+xml', + }; + + return mimeTypes[ext || ''] || 'application/octet-stream'; +} + +/** + * Convert a Web ReadableStream to a Node.js Readable stream + */ +function readableStreamToNodeReadable( + webStream: ReadableStream, +): NodeJS.ReadableStream { + const reader = webStream.getReader(); + + return new Readable({ + async read() { + const { done, value } = await reader.read(); + if (done) { + this.push(null); + } else { + this.push(Buffer.from(value)); + } + }, + }); +} diff --git a/e2e-chatbot-app-next/tests/e2e/chat-resume.test.ts b/e2e-chatbot-app-next/tests/e2e/chat-resume.test.ts index 5cdbe668..1936e50d 100644 --- a/e2e-chatbot-app-next/tests/e2e/chat-resume.test.ts +++ b/e2e-chatbot-app-next/tests/e2e/chat-resume.test.ts @@ -73,7 +73,7 @@ test.describe('Chat stream resume behavior', () => { await chatPage.isGenerationComplete(); // Count assistant messages - should be exactly 1 - const assistantMessages = await chatPage['page'] + const assistantMessages = await chatPage.page .getByTestId('message-assistant') .count(); expect(assistantMessages).toBe(1); From 3530cff8e244cd9fb2c2accaf086e760a495368d Mon Sep 17 00:00:00 2001 From: Emil Lysgaard Date: Mon, 8 Dec 2025 14:56:57 +0100 Subject: [PATCH 02/11] Switch to react-pdf and add text highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace @react-pdf-viewer with react-pdf (more actively maintained) - Add manual text highlighting for cited text fragments - Only highlight on the initial page from citation - Change backend endpoint to POST /api/files/databricks-file - Use official Databricks Files API path (/api/2.0/fs/files/) - Fetch PDF via POST with path in body, return blob URL - Add proper error handling for streams to prevent server crashes - Add CSS styles for highlighted text spans 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- e2e-chatbot-app-next/client/package.json | 1 + .../pdf-preview/PDFCitationLink.tsx | 5 +- .../pdf-preview/PDFPreviewSheet.tsx | 124 ++- .../src/components/pdf-preview/PDFViewer.tsx | 217 ++++-- .../components/pdf-preview/pdf-highlight.css | 6 + .../client/src/lib/pdf-utils.ts | 35 +- e2e-chatbot-app-next/package-lock.json | 720 +++++++++++++++++- .../server/src/routes/files.ts | 99 ++- 8 files changed, 1084 insertions(+), 123 deletions(-) create mode 100644 e2e-chatbot-app-next/client/src/components/pdf-preview/pdf-highlight.css diff --git a/e2e-chatbot-app-next/client/package.json b/e2e-chatbot-app-next/client/package.json index 774f62f3..647da6fe 100644 --- a/e2e-chatbot-app-next/client/package.json +++ b/e2e-chatbot-app-next/client/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-use-controllable-state": "^1.2.2", + "@react-pdf-viewer/highlight": "^3.12.0", "ai": "5.0.76", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFCitationLink.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFCitationLink.tsx index db155f08..97e8829d 100644 --- a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFCitationLink.tsx +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFCitationLink.tsx @@ -40,7 +40,7 @@ export function PDFCitationLink({ children, pdfMetadata }: PDFCitationLinkProps) )} {pdfMetadata.textFragment && ( -
+
"{pdfMetadata.textFragment}"
)} @@ -53,8 +53,9 @@ export function PDFCitationLink({ children, pdfMetadata }: PDFCitationLinkProps) onOpenChange={setIsOpen} filename={pdfMetadata.filename} volumePath={pdfMetadata.volumePath} - downloadUrl={pdfMetadata.downloadUrl} + filePath={pdfMetadata.filePath} initialPage={pdfMetadata.page} + highlightText={pdfMetadata.textFragment} /> ); diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx index e200f971..ba590c58 100644 --- a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx @@ -1,5 +1,11 @@ -import { useState, useCallback } from 'react'; -import { Download, ExternalLink, FileWarning, Lock, AlertCircle } from 'lucide-react'; +import { useState, useCallback, useEffect } from 'react'; +import { + Download, + ExternalLink, + FileWarning, + Lock, + AlertCircle, +} from 'lucide-react'; import { Sheet, @@ -8,16 +14,19 @@ import { SheetTitle, } from '@/components/ui/sheet'; import { Button } from '@/components/ui/button'; +import { Loader } from '@/components/elements/loader'; import { PDFViewer, type PDFError } from './PDFViewer'; -import { getUnityCatalogExplorerUrl } from '@/lib/pdf-utils'; +import { getUnityCatalogExplorerUrl, fetchDatabricksFile } from '@/lib/pdf-utils'; export interface PDFPreviewSheetProps { open: boolean; onOpenChange: (open: boolean) => void; filename: string; volumePath: string; - downloadUrl: string; + /** Full path for the UC file (Volumes/catalog/schema/volume/filename.pdf) */ + filePath: string; initialPage?: number; + highlightText?: string; } function PDFErrorState({ @@ -77,24 +86,88 @@ export function PDFPreviewSheet({ onOpenChange, filename, volumePath, - downloadUrl, + filePath, initialPage, + highlightText, }: PDFPreviewSheetProps) { const [error, setError] = useState(null); const [retryKey, setRetryKey] = useState(0); + const [blobUrl, setBlobUrl] = useState(null); + const [isLoading, setIsLoading] = useState(false); const ucExplorerUrl = getUnityCatalogExplorerUrl(volumePath, filename); + // Fetch the PDF when the sheet opens + useEffect(() => { + if (!open || !filePath) return; + + let cancelled = false; + const currentBlobUrl = blobUrl; + + const loadPdf = async () => { + setIsLoading(true); + setError(null); + + try { + const url = await fetchDatabricksFile(filePath); + if (!cancelled) { + setBlobUrl(url); + } else { + // Clean up if cancelled + URL.revokeObjectURL(url); + } + } catch (err) { + if (!cancelled) { + const message = + err instanceof Error ? err.message.toLowerCase() : ''; + if (message.includes('404')) { + setError({ type: 'NotFoundError' }); + } else if (message.includes('403')) { + setError({ type: 'PermissionError' }); + } else { + setError({ + type: 'LoadError', + message: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + loadPdf(); + + return () => { + cancelled = true; + // Revoke old blob URL on cleanup + if (currentBlobUrl) { + URL.revokeObjectURL(currentBlobUrl); + } + }; + }, [open, filePath, retryKey]); + + // Cleanup blob URL when component unmounts or sheet closes + useEffect(() => { + if (!open && blobUrl) { + URL.revokeObjectURL(blobUrl); + setBlobUrl(null); + } + }, [open, blobUrl]); + const handleLoadError = useCallback((err: PDFError) => { setError(err); }, []); const handleRetry = useCallback(() => { setError(null); + setBlobUrl(null); setRetryKey((prev) => prev + 1); }, []); - // Reset error state when sheet closes + // Reset state when sheet closes const handleOpenChange = useCallback( (isOpen: boolean) => { if (!isOpen) { @@ -105,6 +178,24 @@ export function PDFPreviewSheet({ [onOpenChange], ); + // Handle download via POST request + const handleDownload = useCallback(async () => { + try { + const url = blobUrl || (await fetchDatabricksFile(filePath)); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + if (!blobUrl) { + URL.revokeObjectURL(url); + } + } catch (err) { + console.error('Download failed:', err); + } + }, [blobUrl, filePath, filename]); + return ( )} -
- {error ? ( + {isLoading ? ( +
+ +
+ ) : error ? ( - ) : ( + ) : blobUrl ? ( - )} + ) : null}
diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx index 9bfa1c57..c49100e9 100644 --- a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx @@ -1,17 +1,16 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; +import './pdf-highlight.css'; -import { Loader } from '@/components/elements/loader'; import { Button } from '@/components/ui/button'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Loader } from '@/components/elements/loader'; -// Configure PDF.js worker -pdfjs.GlobalWorkerOptions.workerSrc = new URL( - 'pdfjs-dist/build/pdf.worker.min.mjs', - import.meta.url, -).toString(); +// Configure PDF.js worker - use CDN to avoid bundling issues +pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; export type PDFError = | { type: 'NotFoundError' } @@ -21,20 +20,86 @@ export type PDFError = export interface PDFViewerProps { url: string; initialPage?: number; + highlightText?: string; onLoadError?: (error: PDFError) => void; } -export function PDFViewer({ url, initialPage = 1, onLoadError }: PDFViewerProps) { +/** + * Highlight text in the PDF text layer by searching for phrases + * and wrapping matching text nodes with highlight spans. + */ +function highlightTextInPage( + container: HTMLElement, + searchPhrases: string[], +): void { + const textLayer = container.querySelector('.react-pdf__Page__textContent'); + if (!textLayer) return; + + // Get all text spans in the text layer + const textSpans = textLayer.querySelectorAll('span'); + if (textSpans.length === 0) return; + + // Build the full text content and track positions + let fullText = ''; + const spanMap: { start: number; end: number; span: HTMLSpanElement }[] = []; + + textSpans.forEach((span) => { + const start = fullText.length; + const text = span.textContent || ''; + fullText += text; + spanMap.push({ start, end: start + text.length, span }); + }); + + // Normalize text for matching (collapse whitespace) + const normalizedFullText = fullText.toLowerCase(); + + // Find matches for each phrase + for (const phrase of searchPhrases) { + const normalizedPhrase = phrase.toLowerCase().trim(); + if (normalizedPhrase.length < 3) continue; + + let searchStart = 0; + let matchIndex: number; + + while ( + (matchIndex = normalizedFullText.indexOf(normalizedPhrase, searchStart)) !== + -1 + ) { + const matchEnd = matchIndex + normalizedPhrase.length; + + // Find spans that contain this match + for (const { start, end, span } of spanMap) { + // Check if this span overlaps with the match + if (start < matchEnd && end > matchIndex) { + // Add highlight class to the span + span.classList.add('pdf-text-highlight'); + } + } + + searchStart = matchIndex + 1; + } + } +} + +export function PDFViewer({ + url, + initialPage = 1, + highlightText, + onLoadError, +}: PDFViewerProps) { const [numPages, setNumPages] = useState(null); const [pageNumber, setPageNumber] = useState(initialPage); - const [isLoading, setIsLoading] = useState(true); + const [containerWidth, setContainerWidth] = useState(null); + const [pageRendered, setPageRendered] = useState(false); + const pageContainerRef = useRef(null); const onDocumentLoadSuccess = useCallback( ({ numPages }: { numPages: number }) => { setNumPages(numPages); - setIsLoading(false); - // Ensure initial page is within bounds + // Ensure initialPage is within bounds if (initialPage > numPages) { + setPageNumber(numPages); + } else if (initialPage < 1) { setPageNumber(1); } }, @@ -43,8 +108,6 @@ export function PDFViewer({ url, initialPage = 1, onLoadError }: PDFViewerProps) const onDocumentLoadError = useCallback( (error: Error) => { - setIsLoading(false); - // Map error to our error type const errorMessage = error.message?.toLowerCase() || ''; if (errorMessage.includes('404') || errorMessage.includes('not found')) { onLoadError?.({ type: 'NotFoundError' }); @@ -69,60 +132,118 @@ export function PDFViewer({ url, initialPage = 1, onLoadError }: PDFViewerProps) setPageNumber((prev) => Math.min(prev + 1, numPages || prev)); }, [numPages]); + const onPageRenderSuccess = useCallback(() => { + setPageRendered(true); + }, []); + + // Reset pageRendered when page changes + useEffect(() => { + setPageRendered(false); + }, [pageNumber]); + + // Apply text highlighting after page renders - only on the initial page + useEffect(() => { + if (!pageRendered || !highlightText || !pageContainerRef.current) return; + + // Only highlight on the initial page (the one from the citation) + if (pageNumber !== initialPage) { + console.log( + '[PDFViewer] Skipping highlight - not on initial page. Current:', + pageNumber, + 'Initial:', + initialPage, + ); + return; + } + + // Extract phrases from highlight text + const phrases = highlightText + .split(/\n+/) + .map((s) => s.trim()) + .filter((s) => s.length > 3); + + console.log('[PDFViewer] Highlighting on page', pageNumber, 'phrases:', phrases); + + if (phrases.length > 0) { + // Small delay to ensure text layer is fully rendered + const timeoutId = setTimeout(() => { + if (pageContainerRef.current) { + highlightTextInPage(pageContainerRef.current, phrases); + } + }, 100); + + return () => clearTimeout(timeoutId); + } + }, [pageRendered, highlightText, pageNumber, initialPage]); + + // Measure container width for responsive PDF scaling + const containerRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + resizeObserver.observe(node); + // Set initial width + setContainerWidth(node.clientWidth); + } + }, []); + return (
- {/* Page navigation */} - {numPages && numPages > 1 && ( -
- - - Page {pageNumber} of {numPages} - - -
- )} + {/* Navigation controls */} +
+ + + Page {pageNumber} of {numPages || '...'} + + +
{/* PDF content */} -
+
+
} + className="flex flex-col items-center" > - {!isLoading && ( +
+
} + className="shadow-lg" /> - )} +
diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/pdf-highlight.css b/e2e-chatbot-app-next/client/src/components/pdf-preview/pdf-highlight.css new file mode 100644 index 00000000..efdba0b0 --- /dev/null +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/pdf-highlight.css @@ -0,0 +1,6 @@ +/* PDF text highlight styles */ +.pdf-text-highlight { + background-color: rgba(255, 235, 59, 0.4) !important; + border-radius: 2px; + box-shadow: 0 0 0 1px rgba(255, 235, 59, 0.6); +} diff --git a/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts b/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts index 79a297b8..6f8d741e 100644 --- a/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts +++ b/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts @@ -5,10 +5,10 @@ export interface UCPDFMetadata { filename: string; volumePath: string; + /** Full path for the UC file (Volumes/catalog/schema/volume/filename.pdf) */ + filePath: string; page?: number; textFragment?: string; - /** URL to fetch/download the PDF through our backend proxy */ - downloadUrl: string; /** Original Databricks URL (for reference/debugging) */ originalUrl: string; } @@ -61,10 +61,8 @@ export function parseUnityCatalogPDFLink(href: string): UCPDFMetadata | null { } } - // Create the proxy URL that goes through our backend - // The backend will authenticate with Databricks and fetch the file - const volumePathWithFilename = `${match[1]}/${match[2]}`; - const proxyUrl = `/api/files/volumes/${volumePathWithFilename}`; + // Full path for the UC file + const filePath = `${match[1]}/${match[2]}`; // Keep the original URL for reference (e.g., for "Open in Catalog" link) const originalUrl = new URL(href, window.location.origin); @@ -73,9 +71,9 @@ export function parseUnityCatalogPDFLink(href: string): UCPDFMetadata | null { return { volumePath: match[1], filename: match[2], + filePath, page, textFragment, - downloadUrl: proxyUrl, originalUrl: originalUrl.toString(), }; } @@ -105,3 +103,26 @@ export function getUnityCatalogExplorerUrl( // UC expects the filePreviewPath to be encoded return `/explore/data/${volumePath}?filePreviewPath=${encodeURIComponent(filename)}`; } + +/** + * Fetch a Databricks file through our backend proxy. + * Returns a blob URL that can be used as a src for PDF viewers. + */ +export async function fetchDatabricksFile(filePath: string): Promise { + const response = await fetch('/api/files/databricks-file', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ path: filePath }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const message = errorData.error || response.statusText; + throw new Error(`${response.status}: ${message}`); + } + + const blob = await response.blob(); + return URL.createObjectURL(blob); +} diff --git a/e2e-chatbot-app-next/package-lock.json b/e2e-chatbot-app-next/package-lock.json index 93ffa8b7..946eb0f7 100644 --- a/e2e-chatbot-app-next/package-lock.json +++ b/e2e-chatbot-app-next/package-lock.json @@ -47,6 +47,7 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-use-controllable-state": "^1.2.2", + "@react-pdf-viewer/highlight": "^3.12.0", "ai": "5.0.76", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -1862,6 +1863,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, "node_modules/@mermaid-js/parser": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", @@ -3008,6 +3031,30 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-pdf-viewer/core": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/core/-/core-3.12.0.tgz", + "integrity": "sha512-8MsdlQJ4jaw3GT+zpCHS33nwnvzpY0ED6DEahZg9WngG++A5RMhk8LSlxdHelwaFFHFiXBjmOaj2Kpxh50VQRg==", + "license": "https://react-pdf-viewer.dev/license", + "peerDependencies": { + "pdfjs-dist": "^2.16.105 || ^3.0.279", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/highlight": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/highlight/-/highlight-3.12.0.tgz", + "integrity": "sha512-UxdnvnvTjikugOUEj/RIeJm3Lm93aVu+Xr0jMa7d+wrZlHh5b7wTd+FeI6gNk9wdJDrB70oHiCzn6MTbepkc5g==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -4204,6 +4251,14 @@ "dev": true, "license": "MIT" }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4229,6 +4284,20 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ai": { "version": "5.0.76", "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.76.tgz", @@ -4268,7 +4337,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4300,6 +4369,30 @@ "node": ">=14" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -4339,6 +4432,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/baseline-browser-mapping": { "version": "2.8.24", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.24.tgz", @@ -4379,6 +4480,18 @@ "node": ">=18" } }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { "version": "4.27.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", @@ -4488,6 +4601,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -4610,6 +4740,17 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -4694,6 +4835,17 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -4713,6 +4865,14 @@ "node": ">= 12" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/concurrently": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", @@ -4747,6 +4907,14 @@ "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -5396,6 +5564,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -5412,6 +5594,14 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5434,7 +5624,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5682,7 +5872,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/empathic": { @@ -6039,6 +6229,50 @@ "node": ">= 0.8" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6063,6 +6297,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6141,6 +6406,29 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "15.15.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", @@ -6210,6 +6498,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6541,6 +6837,21 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6553,6 +6864,19 @@ "node": ">=0.10.0" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6621,7 +6945,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7130,6 +7454,34 @@ "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/make-event-props": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", @@ -8128,6 +8480,96 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -8233,6 +8675,14 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8271,6 +8721,28 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -8278,6 +8750,38 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8402,6 +8906,17 @@ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT" }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -8409,6 +8924,17 @@ "dev": true, "license": "MIT" }, + "node_modules/path2d-polyfill": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz", + "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -8416,15 +8942,17 @@ "license": "MIT" }, "node_modules/pdfjs-dist": { - "version": "5.4.296", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", - "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "version": "3.11.174", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", + "integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==", "license": "Apache-2.0", + "peer": true, "engines": { - "node": ">=20.16.0 || >=22.3.0" + "node": ">=18" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.80" + "canvas": "^2.11.2", + "path2d-polyfill": "^2.0.1" } }, "node_modules/picocolors": { @@ -8746,6 +9274,18 @@ } } }, + "node_modules/react-pdf/node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -8874,6 +9414,22 @@ "react": ">= 0.14.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -9251,6 +9807,24 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -9428,7 +10002,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9474,6 +10048,14 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9594,6 +10176,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -9708,11 +10325,22 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9741,7 +10369,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9844,6 +10472,33 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/throttleit": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", @@ -9930,6 +10585,14 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -10379,7 +11042,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/uuid": { @@ -10884,6 +11547,37 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true, + "peer": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/e2e-chatbot-app-next/server/src/routes/files.ts b/e2e-chatbot-app-next/server/src/routes/files.ts index 9bca284e..c15d80ff 100644 --- a/e2e-chatbot-app-next/server/src/routes/files.ts +++ b/e2e-chatbot-app-next/server/src/routes/files.ts @@ -15,28 +15,27 @@ export const filesRouter: RouterType = Router(); filesRouter.use(authMiddleware); /** - * GET /api/files/volumes/* - Proxy Unity Catalog volume files through the backend + * POST /api/files/databricks-file - Proxy Databricks files through the backend * * This endpoint proxies requests to Databricks Files API with proper authentication. * The frontend can request files without needing direct Databricks credentials. * - * Example: GET /api/files/volumes/catalog/schema/volume/path/to/file.pdf - * Proxies to: https://{databricks-host}/ajax-api/2.0/fs/files/Volumes/catalog/schema/volume/path/to/file.pdf + * Request body: { "path": "/Volumes/catalog/schema/volume/path/to/file.pdf" } + * Proxies to: https://{databricks-host}/api/2.0/fs/files/Volumes/catalog/schema/volume/path/to/file.pdf + * + * @see https://docs.databricks.com/api/workspace/files/download */ -filesRouter.get( - '/volumes/{*path}', +filesRouter.post( + '/databricks-file', requireAuth, async (req: Request, res: Response) => { try { - // Extract the full path after /volumes/ - // In Express 5 with path-to-regexp v8, {*path} captures all segments as an array - const pathParam = req.params.path; - const volumePath = Array.isArray(pathParam) - ? pathParam.join('/') - : pathParam; - - if (!volumePath) { - return res.status(400).json({ error: 'Volume path is required' }); + const { path: filePath } = req.body as { path?: string }; + + if (!filePath || typeof filePath !== 'string') { + return res + .status(400) + .json({ error: 'File path is required in request body' }); } // Get Databricks host - prefer cached CLI host, fall back to env @@ -46,8 +45,12 @@ filesRouter.get( } // Construct the Databricks Files API URL - // Note: volumePath already includes "Volumes/catalog/schema/volume/filename" - const databricksUrl = `${hostUrl}/ajax-api/2.0/fs/files/${volumePath}`; + // Path should start with /Volumes/ for Unity Catalog volumes + // See: https://docs.databricks.com/api/workspace/files/download + const normalizedPath = filePath.startsWith('/') + ? filePath + : `/${filePath}`; + const databricksUrl = `${hostUrl}/api/2.0/fs/files${normalizedPath}`; console.log(`[Files Proxy] Fetching: ${databricksUrl}`); @@ -83,7 +86,7 @@ filesRouter.get( // Get content type from response or infer from file extension const contentType = - response.headers.get('content-type') || inferContentType(volumePath); + response.headers.get('content-type') || inferContentType(filePath); // Set response headers res.setHeader('Content-Type', contentType); @@ -102,23 +105,31 @@ filesRouter.get( // Stream the response body to the client if (response.body) { - const reader = response.body.getReader(); - - const stream = new ReadableStream({ - async start(controller) { - while (true) { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - break; - } - controller.enqueue(value); - } - }, + const nodeReadable = readableStreamToNodeReadable(response.body); + + // Handle stream errors to prevent server crash + nodeReadable.on('error', (err) => { + console.error('[Files Proxy] Stream read error:', err); + // Only try to send error if response hasn't started + if (!res.headersSent) { + res.status(500).json({ error: 'Stream error while fetching file' }); + } else { + // Response already started, just end it + res.end(); + } + }); + + // Handle response errors + res.on('error', (err) => { + console.error('[Files Proxy] Response write error:', err); + nodeReadable.destroy(); + }); + + // Handle client disconnect + res.on('close', () => { + nodeReadable.destroy(); }); - // Convert ReadableStream to Node.js readable and pipe to response - const nodeReadable = readableStreamToNodeReadable(stream); nodeReadable.pipe(res); } else { // Fallback: read entire body as buffer @@ -128,6 +139,11 @@ filesRouter.get( } catch (error) { console.error('[Files Proxy] Error:', error); + // Don't try to send response if headers already sent + if (res.headersSent) { + return res.end(); + } + if (error instanceof Error) { return res.status(500).json({ error: 'Failed to fetch file', @@ -168,17 +184,24 @@ function inferContentType(path: string): string { */ function readableStreamToNodeReadable( webStream: ReadableStream, -): NodeJS.ReadableStream { +): Readable { const reader = webStream.getReader(); return new Readable({ async read() { - const { done, value } = await reader.read(); - if (done) { - this.push(null); - } else { - this.push(Buffer.from(value)); + try { + const { done, value } = await reader.read(); + if (done) { + this.push(null); + } else { + this.push(Buffer.from(value)); + } + } catch (err) { + this.destroy(err instanceof Error ? err : new Error(String(err))); } }, + destroy(err, callback) { + reader.cancel(err?.message).finally(() => callback(err)); + }, }); } From 76b6c0796b5dac17b7f384119e286afbef50288f Mon Sep 17 00:00:00 2001 From: Emil Lysgaard Date: Tue, 9 Dec 2025 13:22:36 +0100 Subject: [PATCH 03/11] undo port changes --- e2e-chatbot-app-next/client/vite.config.ts | 6 +++--- e2e-chatbot-app-next/server/src/index.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e-chatbot-app-next/client/vite.config.ts b/e2e-chatbot-app-next/client/vite.config.ts index b584314b..4971dc8b 100644 --- a/e2e-chatbot-app-next/client/vite.config.ts +++ b/e2e-chatbot-app-next/client/vite.config.ts @@ -7,14 +7,14 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { - '@': path.resolve(__dirname, './src') + '@': path.resolve(__dirname, './src'), }, }, server: { - port: 4000, + port: 3000, proxy: { '/api': { - target: 'http://localhost:4001', + target: 'http://localhost:3001', changeOrigin: true, }, }, diff --git a/e2e-chatbot-app-next/server/src/index.ts b/e2e-chatbot-app-next/server/src/index.ts index 9790f349..68115351 100644 --- a/e2e-chatbot-app-next/server/src/index.ts +++ b/e2e-chatbot-app-next/server/src/index.ts @@ -25,17 +25,17 @@ const __dirname = dirname(__filename); const app: Express = express(); const isDevelopment = process.env.NODE_ENV !== 'production'; -// Either let PORT be set by env or use 4001 for development and 4000 for production +// Either let PORT be set by env or use 3001 for development and 3000 for production // The CHAT_APP_PORT can be used to override the port for the chat app. const PORT = process.env.CHAT_APP_PORT || process.env.PORT || - (isDevelopment ? 4001 : 4000); + (isDevelopment ? 3001 : 3000); // CORS configuration app.use( cors({ - origin: isDevelopment ? 'http://localhost:4000' : true, + origin: isDevelopment ? 'http://localhost:3000' : true, credentials: true, }), ); From a325db32b6e69e6d1cdb6fa24eb38a3c6fadfac1 Mon Sep 17 00:00:00 2001 From: Emil Lysgaard Date: Tue, 9 Dec 2025 14:18:46 +0100 Subject: [PATCH 04/11] Add comprehensive integration tests for files route - Create tests/routes/files.test.ts with 15 test cases covering: - Request validation (missing/invalid path) - File type handling (PDF, JSON, TXT, images, unknown) - Databricks API integration (404, 403, 500 errors) - Streaming and header forwarding - Path normalization and edge cases - Add Databricks Files API mock handlers to api-mock-handlers.ts - All tests pass (15 passed, 1 skipped) - Skip unauthenticated test due to test environment auth defaults --- .../tests/api-mocking/api-mock-handlers.ts | 143 ++++++++++- .../tests/routes/files.test.ts | 230 ++++++++++++++++++ 2 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 e2e-chatbot-app-next/tests/routes/files.test.ts diff --git a/e2e-chatbot-app-next/tests/api-mocking/api-mock-handlers.ts b/e2e-chatbot-app-next/tests/api-mocking/api-mock-handlers.ts index b4bc53a9..7147f635 100644 --- a/e2e-chatbot-app-next/tests/api-mocking/api-mock-handlers.ts +++ b/e2e-chatbot-app-next/tests/api-mocking/api-mock-handlers.ts @@ -16,11 +16,7 @@ import { TEST_PROMPTS } from '../prompts/routes'; * State machine for MCP approval flow. * This tracks the state of approval requests across multiple API calls. */ -type McpApprovalState = - | 'idle' - | 'awaiting-approval' - | 'approved' - | 'denied'; +type McpApprovalState = 'idle' | 'awaiting-approval' | 'approved' | 'denied'; let mcpApprovalState: McpApprovalState = 'idle'; const MCP_REQUEST_ID = '__fake_mcp_request_id__'; @@ -173,4 +169,141 @@ export const handlers = [ access_token: 'test-token', }); }), + + // Mock Databricks Files API + http.get(/\/api\/2\.0\/fs\/files\/Volumes\//, async (req) => { + const url = new URL(req.request.url); + const path = url.pathname.replace('/api/2.0/fs/files', ''); + + // Handle 404 - file not found + if (path.includes('nonexistent')) { + return new HttpResponse(null, { status: 404 }); + } + + // Handle 403 - forbidden + if (path.includes('forbidden')) { + return new HttpResponse(null, { status: 403 }); + } + + // Handle 500 - server error + if (path.includes('server-error')) { + return new HttpResponse(null, { status: 500 }); + } + + // Handle large file + if (path.includes('large.pdf')) { + const largeBuffer = Buffer.alloc(2 * 1024 * 1024, 'a'); // 2MB file + return new HttpResponse(largeBuffer, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Length': largeBuffer.length.toString(), + }, + }); + } + + // Handle no-stream fallback case (body is null) + if (path.includes('no-stream')) { + const content = Buffer.from('Buffer fallback content'); + return new HttpResponse(content, { + headers: { + 'Content-Type': 'application/pdf', + }, + }); + } + + // Handle file with Content-Disposition + if (path.includes('download.pdf')) { + const content = Buffer.from('Downloadable PDF content'); + return new HttpResponse(content, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Length': content.length.toString(), + 'Content-Disposition': 'attachment; filename="download.pdf"', + }, + }); + } + + // Handle PDF files + if (path.endsWith('.pdf')) { + const content = Buffer.from('Mock PDF content'); + return new HttpResponse(content, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Length': content.length.toString(), + }, + }); + } + + // Handle JSON files + if (path.endsWith('.json')) { + const content = JSON.stringify({ message: 'Mock JSON data' }); + return new HttpResponse(content, { + headers: { + 'Content-Type': 'application/json', + 'Content-Length': content.length.toString(), + }, + }); + } + + // Handle text files + if (path.endsWith('.txt')) { + const content = 'Mock text content'; + return new HttpResponse(content, { + headers: { + 'Content-Type': 'text/plain', + 'Content-Length': content.length.toString(), + }, + }); + } + + // Handle image files + if (path.endsWith('.png')) { + const content = Buffer.from('Mock PNG image'); + return new HttpResponse(content, { + headers: { + 'Content-Type': 'image/png', + 'Content-Length': content.length.toString(), + }, + }); + } + + if (path.endsWith('.jpg') || path.endsWith('.jpeg')) { + const content = Buffer.from('Mock JPEG image'); + return new HttpResponse(content, { + headers: { + 'Content-Type': 'image/jpeg', + 'Content-Length': content.length.toString(), + }, + }); + } + + if (path.endsWith('.gif')) { + const content = Buffer.from('Mock GIF image'); + return new HttpResponse(content, { + headers: { + 'Content-Type': 'image/gif', + 'Content-Length': content.length.toString(), + }, + }); + } + + if (path.endsWith('.svg')) { + const content = ''; + return new HttpResponse(content, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Content-Length': content.length.toString(), + }, + }); + } + + // Default case for unknown file types + const content = Buffer.from('Mock file content'); + return new HttpResponse(content, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': content.length.toString(), + }, + }); + }), ]; diff --git a/e2e-chatbot-app-next/tests/routes/files.test.ts b/e2e-chatbot-app-next/tests/routes/files.test.ts new file mode 100644 index 00000000..185f1cf0 --- /dev/null +++ b/e2e-chatbot-app-next/tests/routes/files.test.ts @@ -0,0 +1,230 @@ +import { expect, test } from '../fixtures'; + +test.describe('/api/files/databricks-file', () => { + test('returns 400 when file path is missing', async ({ adaContext }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: {}, + }, + ); + expect(response.status()).toBe(400); + + const json = await response.json(); + expect(json.error).toEqual('File path is required in request body'); + }); + + test('returns 400 when file path is not a string', async ({ adaContext }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: 123 }, + }, + ); + expect(response.status()).toBe(400); + + const json = await response.json(); + expect(json.error).toEqual('File path is required in request body'); + }); + + test('Ada can fetch a PDF file from Databricks', async ({ adaContext }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/test.pdf' }, + }, + ); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('application/pdf'); + + const buffer = await response.body(); + expect(buffer).toBeTruthy(); + expect(buffer.toString()).toContain('Mock PDF content'); + }); + + test('Ada can fetch a JSON file from Databricks', async ({ adaContext }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/data.json' }, + }, + ); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('application/json'); + + const json = await response.json(); + expect(json).toEqual({ message: 'Mock JSON data' }); + }); + + test('infers content type from file extension', async ({ adaContext }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/document.txt' }, + }, + ); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('text/plain'); + }); + + test('defaults to application/octet-stream for unknown extensions', async ({ + adaContext, + }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/file.unknown' }, + }, + ); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('application/octet-stream'); + }); + + test('handles paths without leading slash', async ({ adaContext }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: 'Volumes/catalog/schema/volume/test.pdf' }, + }, + ); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('application/pdf'); + }); + + test('returns 404 when file not found in Databricks', async ({ + adaContext, + }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/nonexistent.pdf' }, + }, + ); + expect(response.status()).toBe(404); + + const json = await response.json(); + expect(json.error).toEqual('File not found'); + }); + + test('returns 403 when permission denied by Databricks', async ({ + adaContext, + }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/forbidden.pdf' }, + }, + ); + expect(response.status()).toBe(403); + + const json = await response.json(); + expect(json.error).toEqual('Permission denied'); + }); + + test('forwards Content-Length header from Databricks', async ({ + adaContext, + }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/test.pdf' }, + }, + ); + expect(response.status()).toBe(200); + + const contentLength = response.headers()['content-length']; + expect(contentLength).toBeTruthy(); + expect(Number.parseInt(contentLength)).toBeGreaterThan(0); + }); + + test('forwards Content-Disposition header from Databricks', async ({ + adaContext, + }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/download.pdf' }, + }, + ); + expect(response.status()).toBe(200); + + const contentDisposition = response.headers()['content-disposition']; + expect(contentDisposition).toBe('attachment; filename="download.pdf"'); + }); + + test('handles large file streaming', async ({ adaContext }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/large.pdf' }, + }, + ); + expect(response.status()).toBe(200); + + const buffer = await response.body(); + expect(buffer).toBeTruthy(); + // Large file should be at least 1MB + expect(buffer.length).toBeGreaterThan(1024 * 1024); + }); + + test('handles files without body stream (fallback to buffer)', async ({ + adaContext, + }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/no-stream.pdf' }, + }, + ); + expect(response.status()).toBe(200); + + const buffer = await response.body(); + expect(buffer).toBeTruthy(); + expect(buffer.toString()).toContain('Buffer fallback content'); + }); + + test('handles various image file types', async ({ adaContext }) => { + const testCases = [ + { + path: '/Volumes/catalog/schema/volume/image.png', + contentType: 'image/png', + }, + { + path: '/Volumes/catalog/schema/volume/image.jpg', + contentType: 'image/jpeg', + }, + { + path: '/Volumes/catalog/schema/volume/image.gif', + contentType: 'image/gif', + }, + { + path: '/Volumes/catalog/schema/volume/image.svg', + contentType: 'image/svg+xml', + }, + ]; + + for (const { path, contentType } of testCases) { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path }, + }, + ); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe(contentType); + } + }); + + test('handles other Databricks API errors', async ({ adaContext }) => { + const response = await adaContext.request.post( + '/api/files/databricks-file', + { + data: { path: '/Volumes/catalog/schema/volume/server-error.pdf' }, + }, + ); + expect(response.status()).toBe(500); + + const json = await response.json(); + expect(json.error).toContain('Failed to fetch file'); + }); +}); From edcf3bfb4037547b549b2b1c7f1ce03b214147a7 Mon Sep 17 00:00:00 2001 From: Emil Lysgaard Date: Tue, 9 Dec 2025 14:18:57 +0100 Subject: [PATCH 05/11] Add cited text display to PDF preview - Add collapsible section in PDF preview sheet header to show original cited text - Display cited text in a styled box with proper whitespace preservation - Add toggle functionality with chevron icons (defaults to expanded) - Only shows when highlightText prop is provided - Helps users understand what specific content was referenced in citations --- .../pdf-preview/PDFPreviewSheet.tsx | 120 +++++++++++------- 1 file changed, 77 insertions(+), 43 deletions(-) diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx index ba590c58..fedba877 100644 --- a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx @@ -1,22 +1,28 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect } from "react"; import { Download, ExternalLink, FileWarning, Lock, AlertCircle, -} from 'lucide-react'; + ChevronDown, + ChevronUp, + Quote, +} from "lucide-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle, -} from '@/components/ui/sheet'; -import { Button } from '@/components/ui/button'; -import { Loader } from '@/components/elements/loader'; -import { PDFViewer, type PDFError } from './PDFViewer'; -import { getUnityCatalogExplorerUrl, fetchDatabricksFile } from '@/lib/pdf-utils'; +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Loader } from "@/components/elements/loader"; +import { PDFViewer, type PDFError } from "./PDFViewer"; +import { + getUnityCatalogExplorerUrl, + fetchDatabricksFile, +} from "@/lib/pdf-utils"; export interface PDFPreviewSheetProps { open: boolean; @@ -40,27 +46,27 @@ function PDFErrorState({ }) { const getErrorContent = () => { switch (error.type) { - case 'NotFoundError': + case "NotFoundError": return { icon: , - title: 'File not found', + title: "File not found", description: `We could not find the file "${filename}". Please check if the file was moved, renamed, or deleted.`, }; - case 'PermissionError': + case "PermissionError": return { icon: , title: "You can't access this file", description: `You do not have permission to access "${filename}". Please contact an administrator.`, }; - case 'LoadError': + case "LoadError": default: return { icon: , - title: 'Failed to load PDF', + title: "Failed to load PDF", description: - error.type === 'LoadError' && error.message + error.type === "LoadError" && error.message ? error.message - : 'An unexpected error occurred while loading the PDF file.', + : "An unexpected error occurred while loading the PDF file.", }; } }; @@ -94,6 +100,7 @@ export function PDFPreviewSheet({ const [retryKey, setRetryKey] = useState(0); const [blobUrl, setBlobUrl] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [showCitedText, setShowCitedText] = useState(true); const ucExplorerUrl = getUnityCatalogExplorerUrl(volumePath, filename); @@ -118,16 +125,15 @@ export function PDFPreviewSheet({ } } catch (err) { if (!cancelled) { - const message = - err instanceof Error ? err.message.toLowerCase() : ''; - if (message.includes('404')) { - setError({ type: 'NotFoundError' }); - } else if (message.includes('403')) { - setError({ type: 'PermissionError' }); + const message = err instanceof Error ? err.message.toLowerCase() : ""; + if (message.includes("404")) { + setError({ type: "NotFoundError" }); + } else if (message.includes("403")) { + setError({ type: "PermissionError" }); } else { setError({ - type: 'LoadError', - message: err instanceof Error ? err.message : 'Unknown error', + type: "LoadError", + message: err instanceof Error ? err.message : "Unknown error", }); } } @@ -175,14 +181,14 @@ export function PDFPreviewSheet({ } onOpenChange(isOpen); }, - [onOpenChange], + [onOpenChange] ); // Handle download via POST request const handleDownload = useCallback(async () => { try { const url = blobUrl || (await fetchDatabricksFile(filePath)); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); @@ -192,7 +198,7 @@ export function PDFPreviewSheet({ URL.revokeObjectURL(url); } } catch (err) { - console.error('Download failed:', err); + console.error("Download failed:", err); } }, [blobUrl, filePath, filename]); @@ -202,26 +208,54 @@ export function PDFPreviewSheet({ className="flex w-[70vw] max-w-4xl flex-col gap-0 p-0 sm:max-w-4xl" side="right" > - - {filename} -
- {ucExplorerUrl && ( - + )} + - )} - +
+ + {/* Cited text section */} + {highlightText && ( +
+ + {showCitedText && ( +
+

+ {highlightText} +

+
+ )} +
+ )}
From bfa9c1024d950bcb533bd6fcb6f444a178359b9e Mon Sep 17 00:00:00 2001 From: Emil Lysgaard Date: Thu, 11 Dec 2025 13:27:18 +0100 Subject: [PATCH 06/11] Bundle PDF.js worker and remove unused highlight package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Uninstalled @react-pdf-viewer/highlight package (no longer needed) - Removed pdf-highlight.css file and import - Added vite-plugin-static-copy to bundle PDF.js worker file - Updated Vite config to copy worker from node_modules to /assets/ - Changed worker source from CDN to bundled path for offline support The PDF.js worker is now bundled with the application instead of being loaded from unpkg CDN, improving reliability and removing external dependencies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- e2e-chatbot-app-next/client/package.json | 4 +- .../src/components/pdf-preview/PDFViewer.tsx | 5 +- .../components/pdf-preview/pdf-highlight.css | 6 - e2e-chatbot-app-next/client/vite.config.ts | 13 +- e2e-chatbot-app-next/package-lock.json | 906 +++++------------- 5 files changed, 238 insertions(+), 696 deletions(-) delete mode 100644 e2e-chatbot-app-next/client/src/components/pdf-preview/pdf-highlight.css diff --git a/e2e-chatbot-app-next/client/package.json b/e2e-chatbot-app-next/client/package.json index 647da6fe..09a1a453 100644 --- a/e2e-chatbot-app-next/client/package.json +++ b/e2e-chatbot-app-next/client/package.json @@ -30,7 +30,6 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-use-controllable-state": "^1.2.2", - "@react-pdf-viewer/highlight": "^3.12.0", "ai": "5.0.76", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -65,6 +64,7 @@ "tailwindcss": "^4.1.13", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", - "vite": "npm:rolldown-vite@latest" + "vite": "npm:rolldown-vite@latest", + "vite-plugin-static-copy": "^3.1.4" } } diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx index c49100e9..40766549 100644 --- a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx @@ -4,13 +4,12 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; -import './pdf-highlight.css'; import { Button } from '@/components/ui/button'; import { Loader } from '@/components/elements/loader'; -// Configure PDF.js worker - use CDN to avoid bundling issues -pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; +// Configure PDF.js worker - bundled with the application +pdfjs.GlobalWorkerOptions.workerSrc = '/assets/pdf.worker.min.mjs'; export type PDFError = | { type: 'NotFoundError' } diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/pdf-highlight.css b/e2e-chatbot-app-next/client/src/components/pdf-preview/pdf-highlight.css deleted file mode 100644 index efdba0b0..00000000 --- a/e2e-chatbot-app-next/client/src/components/pdf-preview/pdf-highlight.css +++ /dev/null @@ -1,6 +0,0 @@ -/* PDF text highlight styles */ -.pdf-text-highlight { - background-color: rgba(255, 235, 59, 0.4) !important; - border-radius: 2px; - box-shadow: 0 0 0 1px rgba(255, 235, 59, 0.6); -} diff --git a/e2e-chatbot-app-next/client/vite.config.ts b/e2e-chatbot-app-next/client/vite.config.ts index 4971dc8b..34d86bbb 100644 --- a/e2e-chatbot-app-next/client/vite.config.ts +++ b/e2e-chatbot-app-next/client/vite.config.ts @@ -1,10 +1,21 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'node:path'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + viteStaticCopy({ + targets: [ + { + src: '../node_modules/react-pdf/node_modules/pdfjs-dist/build/pdf.worker.min.mjs', + dest: 'assets', + }, + ], + }), + ], resolve: { alias: { '@': path.resolve(__dirname, './src'), diff --git a/e2e-chatbot-app-next/package-lock.json b/e2e-chatbot-app-next/package-lock.json index 946eb0f7..aacb5d86 100644 --- a/e2e-chatbot-app-next/package-lock.json +++ b/e2e-chatbot-app-next/package-lock.json @@ -47,7 +47,6 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-use-controllable-state": "^1.2.2", - "@react-pdf-viewer/highlight": "^3.12.0", "ai": "5.0.76", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -82,7 +81,8 @@ "tailwindcss": "^4.1.13", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", - "vite": "npm:rolldown-vite@latest" + "vite": "npm:rolldown-vite@latest", + "vite-plugin-static-copy": "^3.1.4" } }, "client/node_modules/date-fns": { @@ -1863,28 +1863,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", - "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, "node_modules/@mermaid-js/parser": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", @@ -3031,30 +3009,6 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@react-pdf-viewer/core": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@react-pdf-viewer/core/-/core-3.12.0.tgz", - "integrity": "sha512-8MsdlQJ4jaw3GT+zpCHS33nwnvzpY0ED6DEahZg9WngG++A5RMhk8LSlxdHelwaFFHFiXBjmOaj2Kpxh50VQRg==", - "license": "https://react-pdf-viewer.dev/license", - "peerDependencies": { - "pdfjs-dist": "^2.16.105 || ^3.0.279", - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@react-pdf-viewer/highlight": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@react-pdf-viewer/highlight/-/highlight-3.12.0.tgz", - "integrity": "sha512-UxdnvnvTjikugOUEj/RIeJm3Lm93aVu+Xr0jMa7d+wrZlHh5b7wTd+FeI6gNk9wdJDrB70oHiCzn6MTbepkc5g==", - "license": "https://react-pdf-viewer.dev/license", - "dependencies": { - "@react-pdf-viewer/core": "3.12.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -4251,14 +4205,6 @@ "dev": true, "license": "MIT" }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4284,20 +4230,6 @@ "node": ">=0.4.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ai": { "version": "5.0.76", "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.76.tgz", @@ -4337,7 +4269,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4369,28 +4301,31 @@ "node": ">=14" } }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC", - "optional": true, - "peer": true - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", - "optional": true, - "peer": true, "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": ">=10" + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/aria-hidden": { @@ -4432,14 +4367,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/baseline-browser-mapping": { "version": "2.8.24", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.24.tgz", @@ -4450,6 +4377,19 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/birpc": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.7.0.tgz", @@ -4480,16 +4420,17 @@ "node": ">=18" } }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" } }, "node_modules/browserslist": { @@ -4601,23 +4542,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", - "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.0", - "nan": "^2.17.0", - "simple-get": "^3.0.3" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -4740,17 +4664,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -4835,17 +4748,6 @@ "dev": true, "license": "MIT" }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "color-support": "bin.js" - } - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -4865,14 +4767,6 @@ "node": ">= 12" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/concurrently": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", @@ -4907,14 +4801,6 @@ "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "license": "MIT" }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -5564,20 +5450,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/decompress-response": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", - "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mimic-response": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -5594,14 +5466,6 @@ "robust-predicates": "^3.0.2" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5624,7 +5488,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5872,7 +5736,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/empathic": { @@ -6159,6 +6023,19 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", @@ -6229,50 +6106,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true, - "peer": true - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6297,37 +6130,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/gauge/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6406,27 +6208,17 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", - "optional": true, - "peer": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "is-glob": "^4.0.1" }, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 6" } }, "node_modules/globals": { @@ -6498,14 +6290,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6837,21 +6621,6 @@ "node": ">= 0.8" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6864,19 +6633,6 @@ "node": ">=0.10.0" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6931,6 +6687,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -6941,16 +6710,39 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -6968,6 +6760,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -7454,34 +7256,6 @@ "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" } }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/make-event-props": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", @@ -8480,96 +8254,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", - "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true, - "peer": true - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -8675,14 +8359,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/nan": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", - "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8721,28 +8397,6 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -8750,36 +8404,14 @@ "dev": true, "license": "MIT" }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6" - } - }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" + "node": ">=0.10.0" } }, "node_modules/object-assign": { @@ -8848,6 +8480,19 @@ "dev": true, "license": "MIT" }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-manager-detector": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.5.0.tgz", @@ -8906,17 +8551,6 @@ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT" }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -8924,37 +8558,12 @@ "dev": true, "license": "MIT" }, - "node_modules/path2d-polyfill": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz", - "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, - "node_modules/pdfjs-dist": { - "version": "3.11.174", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", - "integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==", - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "canvas": "^2.11.2", - "path2d-polyfill": "^2.0.1" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9414,22 +9023,6 @@ "react": ">= 0.14.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -9807,24 +9400,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -10002,7 +9577,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10048,14 +9623,6 @@ "node": ">= 18" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10176,41 +9743,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/simple-get": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", - "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "decompress-response": "^4.2.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -10325,22 +9857,11 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10369,7 +9890,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -10472,33 +9993,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/throttleit": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", @@ -10563,6 +10057,19 @@ "dev": true, "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -10585,14 +10092,6 @@ "node": ">=16" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11042,7 +10541,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -11186,6 +10685,76 @@ } } }, + "node_modules/vite-plugin-static-copy": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz", + "integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.3", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.45", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.45.tgz", @@ -11547,37 +11116,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true, - "peer": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", From 99897b4f9c637de603c08e56bd7426f2521a8b41 Mon Sep 17 00:00:00 2001 From: Emil Lysgaard Date: Thu, 11 Dec 2025 15:02:45 +0100 Subject: [PATCH 07/11] Remove highlighting code from PDFViewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused highlighting code that was accidentally left in the PDFViewer component. Highlighting will be added in a separate PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../pdf-preview/PDFPreviewSheet.tsx | 49 ++++--- .../src/components/pdf-preview/PDFViewer.tsx | 135 ++---------------- 2 files changed, 36 insertions(+), 148 deletions(-) diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx index fedba877..14469551 100644 --- a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect } from 'react'; import { Download, ExternalLink, @@ -8,21 +8,21 @@ import { ChevronDown, ChevronUp, Quote, -} from "lucide-react"; +} from 'lucide-react'; import { Sheet, SheetContent, SheetHeader, SheetTitle, -} from "@/components/ui/sheet"; -import { Button } from "@/components/ui/button"; -import { Loader } from "@/components/elements/loader"; -import { PDFViewer, type PDFError } from "./PDFViewer"; +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Loader } from '@/components/elements/loader'; +import { PDFViewer, type PDFError } from './PDFViewer'; import { getUnityCatalogExplorerUrl, fetchDatabricksFile, -} from "@/lib/pdf-utils"; +} from '@/lib/pdf-utils'; export interface PDFPreviewSheetProps { open: boolean; @@ -46,27 +46,27 @@ function PDFErrorState({ }) { const getErrorContent = () => { switch (error.type) { - case "NotFoundError": + case 'NotFoundError': return { icon: , - title: "File not found", + title: 'File not found', description: `We could not find the file "${filename}". Please check if the file was moved, renamed, or deleted.`, }; - case "PermissionError": + case 'PermissionError': return { icon: , title: "You can't access this file", description: `You do not have permission to access "${filename}". Please contact an administrator.`, }; - case "LoadError": + case 'LoadError': default: return { icon: , - title: "Failed to load PDF", + title: 'Failed to load PDF', description: - error.type === "LoadError" && error.message + error.type === 'LoadError' && error.message ? error.message - : "An unexpected error occurred while loading the PDF file.", + : 'An unexpected error occurred while loading the PDF file.', }; } }; @@ -125,15 +125,15 @@ export function PDFPreviewSheet({ } } catch (err) { if (!cancelled) { - const message = err instanceof Error ? err.message.toLowerCase() : ""; - if (message.includes("404")) { - setError({ type: "NotFoundError" }); - } else if (message.includes("403")) { - setError({ type: "PermissionError" }); + const message = err instanceof Error ? err.message.toLowerCase() : ''; + if (message.includes('404')) { + setError({ type: 'NotFoundError' }); + } else if (message.includes('403')) { + setError({ type: 'PermissionError' }); } else { setError({ - type: "LoadError", - message: err instanceof Error ? err.message : "Unknown error", + type: 'LoadError', + message: err instanceof Error ? err.message : 'Unknown error', }); } } @@ -181,14 +181,14 @@ export function PDFPreviewSheet({ } onOpenChange(isOpen); }, - [onOpenChange] + [onOpenChange], ); // Handle download via POST request const handleDownload = useCallback(async () => { try { const url = blobUrl || (await fetchDatabricksFile(filePath)); - const a = document.createElement("a"); + const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); @@ -198,7 +198,7 @@ export function PDFPreviewSheet({ URL.revokeObjectURL(url); } } catch (err) { - console.error("Download failed:", err); + console.error('Download failed:', err); } }, [blobUrl, filePath, filename]); @@ -274,7 +274,6 @@ export function PDFPreviewSheet({ key={retryKey} url={blobUrl} initialPage={initialPage} - highlightText={highlightText} onLoadError={handleLoadError} /> ) : null} diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx index 40766549..11f03d6f 100644 --- a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import { ChevronLeft, ChevronRight } from 'lucide-react'; @@ -19,78 +19,17 @@ export type PDFError = export interface PDFViewerProps { url: string; initialPage?: number; - highlightText?: string; onLoadError?: (error: PDFError) => void; } -/** - * Highlight text in the PDF text layer by searching for phrases - * and wrapping matching text nodes with highlight spans. - */ -function highlightTextInPage( - container: HTMLElement, - searchPhrases: string[], -): void { - const textLayer = container.querySelector('.react-pdf__Page__textContent'); - if (!textLayer) return; - - // Get all text spans in the text layer - const textSpans = textLayer.querySelectorAll('span'); - if (textSpans.length === 0) return; - - // Build the full text content and track positions - let fullText = ''; - const spanMap: { start: number; end: number; span: HTMLSpanElement }[] = []; - - textSpans.forEach((span) => { - const start = fullText.length; - const text = span.textContent || ''; - fullText += text; - spanMap.push({ start, end: start + text.length, span }); - }); - - // Normalize text for matching (collapse whitespace) - const normalizedFullText = fullText.toLowerCase(); - - // Find matches for each phrase - for (const phrase of searchPhrases) { - const normalizedPhrase = phrase.toLowerCase().trim(); - if (normalizedPhrase.length < 3) continue; - - let searchStart = 0; - let matchIndex: number; - - while ( - (matchIndex = normalizedFullText.indexOf(normalizedPhrase, searchStart)) !== - -1 - ) { - const matchEnd = matchIndex + normalizedPhrase.length; - - // Find spans that contain this match - for (const { start, end, span } of spanMap) { - // Check if this span overlaps with the match - if (start < matchEnd && end > matchIndex) { - // Add highlight class to the span - span.classList.add('pdf-text-highlight'); - } - } - - searchStart = matchIndex + 1; - } - } -} - export function PDFViewer({ url, initialPage = 1, - highlightText, onLoadError, }: PDFViewerProps) { const [numPages, setNumPages] = useState(null); const [pageNumber, setPageNumber] = useState(initialPage); const [containerWidth, setContainerWidth] = useState(null); - const [pageRendered, setPageRendered] = useState(false); - const pageContainerRef = useRef(null); const onDocumentLoadSuccess = useCallback( ({ numPages }: { numPages: number }) => { @@ -131,50 +70,6 @@ export function PDFViewer({ setPageNumber((prev) => Math.min(prev + 1, numPages || prev)); }, [numPages]); - const onPageRenderSuccess = useCallback(() => { - setPageRendered(true); - }, []); - - // Reset pageRendered when page changes - useEffect(() => { - setPageRendered(false); - }, [pageNumber]); - - // Apply text highlighting after page renders - only on the initial page - useEffect(() => { - if (!pageRendered || !highlightText || !pageContainerRef.current) return; - - // Only highlight on the initial page (the one from the citation) - if (pageNumber !== initialPage) { - console.log( - '[PDFViewer] Skipping highlight - not on initial page. Current:', - pageNumber, - 'Initial:', - initialPage, - ); - return; - } - - // Extract phrases from highlight text - const phrases = highlightText - .split(/\n+/) - .map((s) => s.trim()) - .filter((s) => s.length > 3); - - console.log('[PDFViewer] Highlighting on page', pageNumber, 'phrases:', phrases); - - if (phrases.length > 0) { - // Small delay to ensure text layer is fully rendered - const timeoutId = setTimeout(() => { - if (pageContainerRef.current) { - highlightTextInPage(pageContainerRef.current, phrases); - } - }, 100); - - return () => clearTimeout(timeoutId); - } - }, [pageRendered, highlightText, pageNumber, initialPage]); - // Measure container width for responsive PDF scaling const containerRef = useCallback((node: HTMLDivElement | null) => { if (node) { @@ -215,10 +110,7 @@ export function PDFViewer({
{/* PDF content */} -
+
-
- - -
- } - className="shadow-lg" - /> -
+ + +
+ } + className="shadow-lg" + /> From 29b273d6ecd11f6b19fd075e5f0c53f6209b8616 Mon Sep 17 00:00:00 2001 From: Emil Lysgaard Date: Thu, 11 Dec 2025 15:13:36 +0100 Subject: [PATCH 08/11] Add pdfjs-dist as explicit dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure reliable worker file path in vite-plugin-static-copy by adding pdfjs-dist as an explicit dependency instead of relying on it being hoisted from react-pdf's node_modules. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- e2e-chatbot-app-next/client/package.json | 1 + e2e-chatbot-app-next/client/vite.config.ts | 2 +- e2e-chatbot-app-next/package-lock.json | 25 +++++++++++----------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/e2e-chatbot-app-next/client/package.json b/e2e-chatbot-app-next/client/package.json index 09a1a453..912abc90 100644 --- a/e2e-chatbot-app-next/client/package.json +++ b/e2e-chatbot-app-next/client/package.json @@ -38,6 +38,7 @@ "framer-motion": "^11.3.19", "lucide-react": "^0.446.0", "next-themes": "^0.4.6", + "pdfjs-dist": "^5.4.296", "react": "^18.2.0", "react-dom": "^18.2.0", "react-pdf": "^10.2.0", diff --git a/e2e-chatbot-app-next/client/vite.config.ts b/e2e-chatbot-app-next/client/vite.config.ts index 34d86bbb..e452039e 100644 --- a/e2e-chatbot-app-next/client/vite.config.ts +++ b/e2e-chatbot-app-next/client/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ viteStaticCopy({ targets: [ { - src: '../node_modules/react-pdf/node_modules/pdfjs-dist/build/pdf.worker.min.mjs', + src: '../node_modules/pdfjs-dist/build/pdf.worker.min.mjs', dest: 'assets', }, ], diff --git a/e2e-chatbot-app-next/package-lock.json b/e2e-chatbot-app-next/package-lock.json index aacb5d86..de110789 100644 --- a/e2e-chatbot-app-next/package-lock.json +++ b/e2e-chatbot-app-next/package-lock.json @@ -55,6 +55,7 @@ "framer-motion": "^11.3.19", "lucide-react": "^0.446.0", "next-themes": "^0.4.6", + "pdfjs-dist": "^5.4.296", "react": "^18.2.0", "react-dom": "^18.2.0", "react-pdf": "^10.2.0", @@ -8564,6 +8565,18 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8883,18 +8896,6 @@ } } }, - "node_modules/react-pdf/node_modules/pdfjs-dist": { - "version": "5.4.296", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", - "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.16.0 || >=22.3.0" - }, - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.80" - } - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", From 47222a2f3720301ed4a7f00957d255c240822d2e Mon Sep 17 00:00:00 2001 From: Emil Lysgaard Date: Thu, 11 Dec 2025 15:15:26 +0100 Subject: [PATCH 09/11] Fix close button overlapping with download button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add right padding to header buttons to prevent overlap with the sheet's close button - Fix "Open in Catalog" link to use full workspace URL instead of relative path 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/pdf-preview/PDFPreviewSheet.tsx | 10 ++++++++-- .../client/src/contexts/AppConfigContext.tsx | 3 +++ e2e-chatbot-app-next/client/src/lib/pdf-utils.ts | 7 ++++++- e2e-chatbot-app-next/server/src/routes/config.ts | 9 +++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx index 14469551..4005a053 100644 --- a/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx @@ -23,6 +23,7 @@ import { getUnityCatalogExplorerUrl, fetchDatabricksFile, } from '@/lib/pdf-utils'; +import { useAppConfig } from '@/contexts/AppConfigContext'; export interface PDFPreviewSheetProps { open: boolean; @@ -101,8 +102,13 @@ export function PDFPreviewSheet({ const [blobUrl, setBlobUrl] = useState(null); const [isLoading, setIsLoading] = useState(false); const [showCitedText, setShowCitedText] = useState(true); + const { workspaceUrl } = useAppConfig(); - const ucExplorerUrl = getUnityCatalogExplorerUrl(volumePath, filename); + const ucExplorerUrl = getUnityCatalogExplorerUrl( + volumePath, + filename, + workspaceUrl, + ); // Fetch the PDF when the sheet opens useEffect(() => { @@ -211,7 +217,7 @@ export function PDFPreviewSheet({
{filename} -
+
{ucExplorerUrl && ( + (null); - const [retryKey, setRetryKey] = useState(0); - const [blobUrl, setBlobUrl] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [loadingState, dispatch] = useReducer(loadingReducer, initialLoadingState); + const [retryCount, setRetryCount] = useState(0); const [showCitedText, setShowCitedText] = useState(true); const { workspaceUrl } = useAppConfig(); + // Resize state + const [width, setWidth] = useState(() => + Math.round((window.innerWidth * DEFAULT_WIDTH_PERCENT) / 100) + ); + const [isResizing, setIsResizing] = useState(false); + const resizeStartX = useRef(0); + const resizeStartWidth = useRef(0); + + // Ref to track blob URL for cleanup (avoids dependency issues with useEffect) + const blobUrlRef = useRef(null); + + // Derive values from state for easier access + const isLoading = loadingState.status === 'loading'; + const error = loadingState.status === 'error' ? loadingState.error : null; + const blobUrl = loadingState.status === 'success' ? loadingState.blobUrl : null; + + // Keep ref in sync with state + useEffect(() => { + blobUrlRef.current = blobUrl; + }, [blobUrl]); + const ucExplorerUrl = getUnityCatalogExplorerUrl( volumePath, filename, workspaceUrl, ); - // Fetch the PDF when the sheet opens + // Fetch the PDF when the sheet opens or retryCount changes + // biome-ignore lint/correctness/useExhaustiveDependencies: retryCount is intentionally used to trigger re-fetches useEffect(() => { if (!open || !filePath) return; let cancelled = false; - const currentBlobUrl = blobUrl; const loadPdf = async () => { - setIsLoading(true); - setError(null); + // Cleanup previous blob URL before starting new load + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + + dispatch({ type: 'START_LOADING' }); try { const url = await fetchDatabricksFile(filePath); if (!cancelled) { - setBlobUrl(url); + dispatch({ type: 'LOAD_SUCCESS', blobUrl: url }); } else { // Clean up if cancelled URL.revokeObjectURL(url); } } catch (err) { if (!cancelled) { - const message = err instanceof Error ? err.message.toLowerCase() : ''; - if (message.includes('404')) { - setError({ type: 'NotFoundError' }); - } else if (message.includes('403')) { - setError({ type: 'PermissionError' }); + if (isPDFError(err)) { + dispatch({ type: 'LOAD_ERROR', error: { type: err.type, message: err.message } }); } else { - setError({ - type: 'LoadError', - message: err instanceof Error ? err.message : 'Unknown error', + dispatch({ + type: 'LOAD_ERROR', + error: { + type: 'LoadError', + message: err instanceof Error ? err.message : 'Unknown error', + }, }); } } - } finally { - if (!cancelled) { - setIsLoading(false); - } } }; @@ -154,36 +220,30 @@ export function PDFPreviewSheet({ return () => { cancelled = true; - // Revoke old blob URL on cleanup - if (currentBlobUrl) { - URL.revokeObjectURL(currentBlobUrl); - } }; - }, [open, filePath, retryKey]); - - // Cleanup blob URL when component unmounts or sheet closes - useEffect(() => { - if (!open && blobUrl) { - URL.revokeObjectURL(blobUrl); - setBlobUrl(null); - } - }, [open, blobUrl]); + // retryCount is intentionally included to trigger re-fetches on retry + }, [open, filePath, retryCount]); const handleLoadError = useCallback((err: PDFError) => { - setError(err); + dispatch({ type: 'LOAD_ERROR', error: err }); }, []); const handleRetry = useCallback(() => { - setError(null); - setBlobUrl(null); - setRetryKey((prev) => prev + 1); + // Cleanup is handled in the useEffect when retryCount changes + dispatch({ type: 'RETRY' }); + setRetryCount((prev) => prev + 1); }, []); - // Reset state when sheet closes + // Reset state and cleanup blob URL when sheet closes const handleOpenChange = useCallback( (isOpen: boolean) => { if (!isOpen) { - setError(null); + // Cleanup blob URL via ref + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + dispatch({ type: 'RESET' }); } onOpenChange(isOpen); }, @@ -208,12 +268,53 @@ export function PDFPreviewSheet({ } }, [blobUrl, filePath, filename]); + // Resize handlers + const handleResizeStart = useCallback((e: ReactPointerEvent) => { + e.preventDefault(); + setIsResizing(true); + resizeStartX.current = e.clientX; + resizeStartWidth.current = width; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, [width]); + + const handleResizeMove = useCallback( + (e: ReactPointerEvent) => { + if (!isResizing) return; + + // Calculate new width (dragging left edge, so subtract delta) + const delta = resizeStartX.current - e.clientX; + const newWidth = resizeStartWidth.current + delta; + + // Apply constraints + const maxWidth = Math.round((window.innerWidth * MAX_WIDTH_PERCENT) / 100); + const clampedWidth = Math.max(MIN_WIDTH, Math.min(maxWidth, newWidth)); + + setWidth(clampedWidth); + }, + [isResizing] + ); + + const handleResizeEnd = useCallback((e: ReactPointerEvent) => { + setIsResizing(false); + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + }, []); + return ( + {/* Resize handle */} +
{filename} @@ -279,7 +380,7 @@ export function PDFPreviewSheet({ /> ) : blobUrl ? ( { - if (node) { - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - setContainerWidth(entry.contentRect.width); - } - }); - resizeObserver.observe(node); - // Set initial width - setContainerWidth(node.clientWidth); - } + const containerRef = useRef(null); + + useEffect(() => { + const node = containerRef.current; + if (!node) return; + + // Set initial width + setContainerWidth(node.clientWidth); + + const resizeObserver = new ResizeObserver((entries) => { + // We only observe one element, so take the first entry directly + const entry = entries[0]; + if (entry) { + setContainerWidth(entry.contentRect.width); + } + }); + + resizeObserver.observe(node); + + // Cleanup: disconnect observer when component unmounts or ref changes + return () => { + resizeObserver.disconnect(); + }; }, []); return ( diff --git a/e2e-chatbot-app-next/client/src/lib/pdf-errors.ts b/e2e-chatbot-app-next/client/src/lib/pdf-errors.ts new file mode 100644 index 00000000..b430aac7 --- /dev/null +++ b/e2e-chatbot-app-next/client/src/lib/pdf-errors.ts @@ -0,0 +1,41 @@ +/** + * Typed errors for PDF operations. + * These errors are thrown by fetchDatabricksFile and caught by PDF components. + */ + +export class PDFNotFoundError extends Error { + readonly type = 'NotFoundError' as const; + + constructor(message = 'File not found') { + super(message); + this.name = 'PDFNotFoundError'; + } +} + +export class PDFPermissionError extends Error { + readonly type = 'PermissionError' as const; + + constructor(message = 'Permission denied') { + super(message); + this.name = 'PDFPermissionError'; + } +} + +export class PDFLoadError extends Error { + readonly type = 'LoadError' as const; + + constructor(message = 'Failed to load PDF') { + super(message); + this.name = 'PDFLoadError'; + } +} + +export type PDFError = PDFNotFoundError | PDFPermissionError | PDFLoadError; + +export function isPDFError(error: unknown): error is PDFError { + return ( + error instanceof PDFNotFoundError || + error instanceof PDFPermissionError || + error instanceof PDFLoadError + ); +} diff --git a/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts b/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts index 7427adbb..878aa61b 100644 --- a/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts +++ b/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts @@ -2,6 +2,12 @@ * Utilities for parsing and handling Unity Catalog PDF links. */ +import { + PDFNotFoundError, + PDFPermissionError, + PDFLoadError, +} from './pdf-errors'; + export interface UCPDFMetadata { filename: string; volumePath: string; @@ -52,21 +58,19 @@ export function parseUnityCatalogPDFLink(href: string): UCPDFMetadata | null { } // Check for text fragment in hash (format: :~:text=...) - const textFragmentMatch = hash.match(/:~:text=(.+)$/); - if (textFragmentMatch) { + // Uses .* to match empty text fragments as well + const textFragmentMatch = hash.match(/:~:text=(.*)$/); + if (textFragmentMatch?.[1]) { textFragment = decodeURIComponent(textFragmentMatch[1]); - } else if (hash.startsWith(':~:text=')) { - // Handle case where hash only contains text fragment without page - textFragment = decodeURIComponent(hash.substring(':~:text='.length)); } } // Full path for the UC file const filePath = `${match[1]}/${match[2]}`; - // Keep the original URL for reference (e.g., for "Open in Catalog" link) - const originalUrl = new URL(href, window.location.origin); - originalUrl.searchParams.delete('page'); + // Create clean URL for reference (e.g., for "Open in Catalog" link) + // Remove the page param since it's stored separately + urlObject.searchParams.delete('page'); return { volumePath: match[1], @@ -74,7 +78,7 @@ export function parseUnityCatalogPDFLink(href: string): UCPDFMetadata | null { filePath, page, textFragment, - originalUrl: originalUrl.toString(), + originalUrl: urlObject.toString(), }; } } catch { @@ -112,6 +116,10 @@ export function getUnityCatalogExplorerUrl( /** * Fetch a Databricks file through our backend proxy. * Returns a blob URL that can be used as a src for PDF viewers. + * + * @throws {PDFNotFoundError} When the file is not found (404) + * @throws {PDFPermissionError} When access is denied (403) + * @throws {PDFLoadError} For other errors */ export async function fetchDatabricksFile(filePath: string): Promise { const response = await fetch('/api/files/databricks-file', { @@ -125,7 +133,14 @@ export async function fetchDatabricksFile(filePath: string): Promise { if (!response.ok) { const errorData = await response.json().catch(() => ({})); const message = errorData.error || response.statusText; - throw new Error(`${response.status}: ${message}`); + + if (response.status === 404) { + throw new PDFNotFoundError(message); + } + if (response.status === 403) { + throw new PDFPermissionError(message); + } + throw new PDFLoadError(`${response.status}: ${message}`); } const blob = await response.blob(); diff --git a/e2e-chatbot-app-next/packages/ai-sdk-providers/src/databricks-provider/responses-agent-language-model/responses-agent-language-model.ts b/e2e-chatbot-app-next/packages/ai-sdk-providers/src/databricks-provider/responses-agent-language-model/responses-agent-language-model.ts index deec9ad8..a27ed1fb 100644 --- a/e2e-chatbot-app-next/packages/ai-sdk-providers/src/databricks-provider/responses-agent-language-model/responses-agent-language-model.ts +++ b/e2e-chatbot-app-next/packages/ai-sdk-providers/src/databricks-provider/responses-agent-language-model/responses-agent-language-model.ts @@ -3,7 +3,7 @@ import type { LanguageModelV2CallOptions, LanguageModelV2FinishReason, LanguageModelV2StreamPart, -} from '@ai-sdk/provider'; +} from "@ai-sdk/provider"; import { type ParseResult, combineHeaders, @@ -11,23 +11,23 @@ import { createJsonErrorResponseHandler, createJsonResponseHandler, postJsonToApi, -} from '@ai-sdk/provider-utils'; -import { z } from 'zod/v4'; -import type { DatabricksLanguageModelConfig } from '../databricks-language-model'; +} from "@ai-sdk/provider-utils"; +import { z } from "zod/v4"; +import type { DatabricksLanguageModelConfig } from "../databricks-language-model"; import { responsesAgentResponseSchema, looseResponseAgentChunkSchema, type responsesAgentChunkSchema, -} from './responses-agent-schema'; +} from "./responses-agent-schema"; import { convertResponsesAgentChunkToMessagePart, convertResponsesAgentResponseToMessagePart, -} from './responses-convert-to-message-parts'; -import { convertToResponsesInput } from './responses-convert-to-input'; -import { getDatabricksLanguageModelTransformStream } from '../stream-transformers/databricks-stream-transformer'; +} from "./responses-convert-to-message-parts"; +import { convertToResponsesInput } from "./responses-convert-to-input"; +import { getDatabricksLanguageModelTransformStream } from "../stream-transformers/databricks-stream-transformer"; export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { - readonly specificationVersion = 'v2'; + readonly specificationVersion = "v2"; readonly modelId: string; @@ -45,8 +45,8 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { readonly supportedUrls: Record = {}; async doGenerate( - options: Parameters[0], - ): Promise>> { + options: Parameters[0] + ): Promise>> { const networkArgs = await this.getArgs({ config: this.config, options, @@ -57,7 +57,7 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { const { value: response } = await postJsonToApi({ ...networkArgs, successfulResponseHandler: createJsonResponseHandler( - responsesAgentResponseSchema, + responsesAgentResponseSchema ), failedResponseHandler: createJsonErrorResponseHandler({ errorSchema: z.any(), // TODO: Implement error schema @@ -68,7 +68,7 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { return { content: convertResponsesAgentResponseToMessagePart(response), - finishReason: 'stop', + finishReason: "stop", usage: { inputTokens: 0, outputTokens: 0, @@ -79,8 +79,8 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { } async doStream( - options: Parameters[0], - ): Promise>> { + options: Parameters[0] + ): Promise>> { const networkArgs = await this.getArgs({ config: this.config, options, @@ -96,12 +96,12 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { isRetryable: () => false, }), successfulResponseHandler: createEventSourceResponseHandler( - looseResponseAgentChunkSchema, + looseResponseAgentChunkSchema ), abortSignal: options.abortSignal, }); - let finishReason: LanguageModelV2FinishReason = 'unknown'; + let finishReason: LanguageModelV2FinishReason = "unknown"; const allParts: LanguageModelV2StreamPart[] = []; @@ -113,23 +113,23 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { LanguageModelV2StreamPart >({ start(controller) { - controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.enqueue({ type: "stream-start", warnings: [] }); }, transform(chunk, controller) { if (options.includeRawChunks) { - controller.enqueue({ type: 'raw', rawValue: chunk.rawValue }); + controller.enqueue({ type: "raw", rawValue: chunk.rawValue }); } // handle failed chunk parsing / validation: if (!chunk.success) { - finishReason = 'error'; - controller.enqueue({ type: 'error', error: chunk.error }); + finishReason = "error"; + controller.enqueue({ type: "error", error: chunk.error }); return; } const parts = convertResponsesAgentChunkToMessagePart( - chunk.value, + chunk.value ); allParts.push(...parts); @@ -142,35 +142,35 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { return; } const part = parts[0]; - if (part.type === 'tool-result') { + if (part.type === "tool-result") { // First check if the tool call is in the current stream parts const matchingToolCallInParts = parts.find( (c) => - c.type === 'tool-call' && c.toolCallId === part.toolCallId, + c.type === "tool-call" && c.toolCallId === part.toolCallId ); // Also check if the tool call was emitted earlier in this stream const matchingToolCallInStream = allParts.find( (c) => - c.type === 'tool-call' && c.toolCallId === part.toolCallId, + c.type === "tool-call" && c.toolCallId === part.toolCallId ); if (!matchingToolCallInParts && !matchingToolCallInStream) { // Find the tool call in the prompt (previous messages) const toolCallFromPreviousMessages = options.prompt .flatMap((message) => { - if (typeof message.content === 'string') return []; + if (typeof message.content === "string") return []; return message.content; }) .find( (p) => - p.type === 'tool-call' && - p.toolCallId === part.toolCallId, + p.type === "tool-call" && + p.toolCallId === part.toolCallId ); if (!toolCallFromPreviousMessages) { throw new Error( - 'No matching tool call found in previous message', + "No matching tool call found in previous message" ); } - if (toolCallFromPreviousMessages.type === 'tool-call') { + if (toolCallFromPreviousMessages.type === "tool-call") { controller.enqueue({ ...toolCallFromPreviousMessages, input: JSON.stringify(toolCallFromPreviousMessages.input), @@ -184,7 +184,7 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { if ( shouldDedupeOutputItemDone( parts, - allParts.slice(0, -parts.length), + allParts.slice(0, -parts.length) ) ) { return; @@ -196,7 +196,7 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { flush(controller) { controller.enqueue({ - type: 'finish', + type: "finish", finishReason, usage: { inputTokens: 0, @@ -205,7 +205,7 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { }, }); }, - }), + }) ) .pipeThrough(getDatabricksLanguageModelTransformStream()), request: { body: networkArgs.body }, @@ -226,11 +226,11 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { }) { const { input } = await convertToResponsesInput({ prompt: options.prompt, - systemMessageMode: 'system', + systemMessageMode: "system", }); return { url: config.url({ - path: '/responses', + path: "/responses", }), headers: combineHeaders(config.headers(), options.headers), body: { @@ -245,19 +245,19 @@ export class DatabricksResponsesAgentLanguageModel implements LanguageModelV2 { function shouldDedupeOutputItemDone( incomingParts: LanguageModelV2StreamPart[], - previousParts: LanguageModelV2StreamPart[], + previousParts: LanguageModelV2StreamPart[] ): boolean { // Determine if the incoming parts contain a text-delta that is a response.output_item.done const doneTextDelta = incomingParts.find( (p) => - p.type === 'text-delta' && - p.providerMetadata?.databricks?.itemType === 'response.output_item.done', + p.type === "text-delta" && + p.providerMetadata?.databricks?.itemType === "response.output_item.done" ); // If the incoming parts do not contain a text-delta that is a response.output_item.done, return false if ( !doneTextDelta || - doneTextDelta.type !== 'text-delta' || + doneTextDelta.type !== "text-delta" || !doneTextDelta.id ) { return false; @@ -271,25 +271,34 @@ function shouldDedupeOutputItemDone( * uses response.output_text.delta and response.output_text.annotation.added events. So we reconstruct all the * delta text and check if the .done text is contained in it (meaning we've already streamed it). */ - // 1. Reconstruct the last contiguous text block from previous text-deltas - // We iterate backwards to get the most recent text block - let reconstructedText = ''; - for (let i = previousParts.length - 1; i >= 0; i--) { + // 1. Reconstruct all text blocks from previous parts and separate them by non-text-delta parts + const reconstructuredTexts: string[] = []; + let currentText = ""; + for (let i = 0; i < previousParts.length; i++) { const part = previousParts[i]; - if (part.type === 'text-delta') { - reconstructedText = part.delta + reconstructedText; - } else { - // We've hit a non-text-delta part, stop here - break; + if (part.type === "text-delta") { + currentText += part.delta; + } else if (currentText.trim().length > 0) { + reconstructuredTexts.push(currentText.trim()); + currentText = ""; } } + reconstructuredTexts.push(currentText); - // 2. Check if the reconstructed delta text is present in the .done text - // The .done text may include footnote syntax like [^ref] that wasn't in the deltas - // If the .done text contains all the delta text, we should dedupe it - if (reconstructedText.length === 0) { + // 2. check if the .done text is contained in the reconstructed text and that it follows the same order as the previous parts + if (reconstructuredTexts.length === 0) { return false; } - - return doneTextDelta.delta.includes(reconstructedText); + let lastIndex = 0; + for (let i = 0; i < reconstructuredTexts.length; i++) { + const indexOfReconstructedText = doneTextDelta.delta.indexOf( + reconstructuredTexts[i], + lastIndex + ); + if (indexOfReconstructedText === -1) { + return false; + } + lastIndex = indexOfReconstructedText + reconstructuredTexts[i].length; + } + return true; } diff --git a/e2e-chatbot-app-next/server/src/routes/files.ts b/e2e-chatbot-app-next/server/src/routes/files.ts index c15d80ff..5fdb9a0b 100644 --- a/e2e-chatbot-app-next/server/src/routes/files.ts +++ b/e2e-chatbot-app-next/server/src/routes/files.ts @@ -38,6 +38,16 @@ filesRouter.post( .json({ error: 'File path is required in request body' }); } + // Validate that the path is a Unity Catalog Volumes path + // Expected format: /Volumes/catalog/schema/volume/path/to/file or Volumes/catalog/schema/volume/path/to/file + const volumesPathRegex = /^\/?Volumes\/[^/]+\/[^/]+\/[^/]+\/.+$/; + if (!volumesPathRegex.test(filePath)) { + return res.status(400).json({ + error: + 'Invalid file path format. Path must be a Unity Catalog Volumes path (e.g., /Volumes/catalog/schema/volume/file.pdf)', + }); + } + // Get Databricks host - prefer cached CLI host, fall back to env let hostUrl = getCachedCliHost(); if (!hostUrl) { @@ -50,7 +60,14 @@ filesRouter.post( const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`; - const databricksUrl = `${hostUrl}/api/2.0/fs/files${normalizedPath}`; + + // URL-encode each path segment to handle spaces and special characters + const encodedPath = normalizedPath + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); + + const databricksUrl = `${hostUrl}/api/2.0/fs/files${encodedPath}`; console.log(`[Files Proxy] Fetching: ${databricksUrl}`);