diff --git a/e2e-chatbot-app-next/client/package.json b/e2e-chatbot-app-next/client/package.json index 82c49320..6a753520 100644 --- a/e2e-chatbot-app-next/client/package.json +++ b/e2e-chatbot-app-next/client/package.json @@ -38,8 +38,10 @@ "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", "react-router-dom": "^6.22.0", "react-syntax-highlighter": "^15.6.6", "sonner": "^1.5.0", @@ -63,6 +65,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/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..39540e2a --- /dev/null +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFCitationLink.tsx @@ -0,0 +1,64 @@ +import { useState, useCallback, type ReactNode } from 'react'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; +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..e08a5488 --- /dev/null +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFPreviewSheet.tsx @@ -0,0 +1,393 @@ +import { + useReducer, + useState, + useCallback, + useEffect, + useRef, + type PointerEvent as ReactPointerEvent, +} from 'react'; +import { + Download, + ExternalLink, + FileWarning, + Lock, + AlertCircle, + 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'; +import { isPDFError, } from '@/lib/pdf-errors'; +import { useAppConfig } from '@/contexts/AppConfigContext'; + +export interface PDFPreviewSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + filename: string; + volumePath: string; + /** Full path for the UC file (Volumes/catalog/schema/volume/filename.pdf) */ + filePath: string; + initialPage?: number; + highlightText?: string; +} + +// State machine for PDF loading +type LoadingState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; blobUrl: string } + | { status: 'error'; error: PDFError }; + +type LoadingAction = + | { type: 'START_LOADING' } + | { type: 'LOAD_SUCCESS'; blobUrl: string } + | { type: 'LOAD_ERROR'; error: PDFError } + | { type: 'RETRY' } + | { type: 'RESET' }; + +function loadingReducer(state: LoadingState, action: LoadingAction): LoadingState { + switch (action.type) { + case 'START_LOADING': + return { status: 'loading' }; + case 'LOAD_SUCCESS': + return { status: 'success', blobUrl: action.blobUrl }; + case 'LOAD_ERROR': + return { status: 'error', error: action.error }; + case 'RETRY': + return { status: 'loading' }; + case 'RESET': + return { status: 'idle' }; + default: + return state; + } +} + +const initialLoadingState: LoadingState = { status: 'idle' }; + +// Resize constraints +const MIN_WIDTH = 400; +const MAX_WIDTH_PERCENT = 95; +const DEFAULT_WIDTH_PERCENT = 85; + +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, + filePath, + initialPage, + highlightText, +}: PDFPreviewSheetProps) { + 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 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 loadPdf = async () => { + // 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) { + dispatch({ type: 'LOAD_SUCCESS', blobUrl: url }); + } else { + // Clean up if cancelled + URL.revokeObjectURL(url); + } + } catch (err) { + if (!cancelled) { + if (isPDFError(err)) { + dispatch({ type: 'LOAD_ERROR', error: { type: err.type, message: err.message } }); + } else { + dispatch({ + type: 'LOAD_ERROR', + error: { + type: 'LoadError', + message: err instanceof Error ? err.message : 'Unknown error', + }, + }); + } + } + } + }; + + loadPdf(); + + return () => { + cancelled = true; + }; + // retryCount is intentionally included to trigger re-fetches on retry + }, [open, filePath, retryCount]); + + const handleLoadError = useCallback((err: PDFError) => { + dispatch({ type: 'LOAD_ERROR', error: err }); + }, []); + + const handleRetry = useCallback(() => { + // Cleanup is handled in the useEffect when retryCount changes + dispatch({ type: 'RETRY' }); + setRetryCount((prev) => prev + 1); + }, []); + + // Reset state and cleanup blob URL when sheet closes + const handleOpenChange = useCallback( + (isOpen: boolean) => { + if (!isOpen) { + // Cleanup blob URL via ref + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + dispatch({ type: 'RESET' }); + } + onOpenChange(isOpen); + }, + [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]); + + // 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} +
+ {ucExplorerUrl && ( + + )} + +
+
+ + {/* Cited text section */} + {highlightText && ( +
+ + {showCitedText && ( +
+

+ {highlightText} +

+
+ )} +
+ )} +
+ +
+ {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 new file mode 100644 index 00000000..07bad18f --- /dev/null +++ b/e2e-chatbot-app-next/client/src/components/pdf-preview/PDFViewer.tsx @@ -0,0 +1,151 @@ +import { useState, useCallback, useRef, useEffect } 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 { Button } from '@/components/ui/button'; +import { Loader } from '@/components/elements/loader'; + +// Configure PDF.js worker - bundled with the application +pdfjs.GlobalWorkerOptions.workerSrc = '/assets/pdf.worker.min.mjs'; + +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 [containerWidth, setContainerWidth] = useState(null); + + const onDocumentLoadSuccess = useCallback( + ({ numPages }: { numPages: number }) => { + setNumPages(numPages); + // Ensure initialPage is within bounds + if (initialPage > numPages) { + setPageNumber(numPages); + } else if (initialPage < 1) { + setPageNumber(1); + } + }, + [initialPage], + ); + + const onDocumentLoadError = useCallback( + (error: Error) => { + 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]); + + // Measure container width for responsive PDF scaling + 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 ( +
+ {/* Navigation controls */} +
+ + + Page {pageNumber} of {numPages || '...'} + + +
+ + {/* PDF content */} +
+ + +
+ } + className="flex flex-col items-center" + > + + +
+ } + className="shadow-lg" + /> + +
+ + ); +} 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/contexts/AppConfigContext.tsx b/e2e-chatbot-app-next/client/src/contexts/AppConfigContext.tsx index 36b0706a..106bc10d 100644 --- a/e2e-chatbot-app-next/client/src/contexts/AppConfigContext.tsx +++ b/e2e-chatbot-app-next/client/src/contexts/AppConfigContext.tsx @@ -6,6 +6,7 @@ interface ConfigResponse { features: { chatHistory: boolean; }; + workspaceUrl?: string; } interface AppConfigContextType { @@ -13,6 +14,7 @@ interface AppConfigContextType { isLoading: boolean; error: Error | undefined; chatHistoryEnabled: boolean; + workspaceUrl: string | undefined; } const AppConfigContext = createContext( @@ -37,6 +39,7 @@ export function AppConfigProvider({ children }: { children: ReactNode }) { error, // Default to true until loaded to avoid breaking existing behavior chatHistoryEnabled: data?.features.chatHistory ?? true, + workspaceUrl: data?.workspaceUrl, }; 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 new file mode 100644 index 00000000..878aa61b --- /dev/null +++ b/e2e-chatbot-app-next/client/src/lib/pdf-utils.ts @@ -0,0 +1,148 @@ +/** + * Utilities for parsing and handling Unity Catalog PDF links. + */ + +import { + PDFNotFoundError, + PDFPermissionError, + PDFLoadError, +} from './pdf-errors'; + +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; + /** 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=...) + // Uses .* to match empty text fragments as well + const textFragmentMatch = hash.match(/:~:text=(.*)$/); + if (textFragmentMatch?.[1]) { + textFragment = decodeURIComponent(textFragmentMatch[1]); + } + } + + // Full path for the UC file + const filePath = `${match[1]}/${match[2]}`; + + // 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], + filename: match[2], + filePath, + page, + textFragment, + originalUrl: urlObject.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, + workspaceUrl?: string, +): string { + if (!filename || !volumePath) { + return ''; + } + // UC expects the filePreviewPath to be encoded + const path = `/explore/data/${volumePath}?filePreviewPath=${encodeURIComponent(filename)}`; + if (workspaceUrl) { + return `${workspaceUrl}${path}`; + } + return path; +} + +/** + * 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', { + 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; + + 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(); + return URL.createObjectURL(blob); +} diff --git a/e2e-chatbot-app-next/client/vite.config.ts b/e2e-chatbot-app-next/client/vite.config.ts index 40d84747..e452039e 100644 --- a/e2e-chatbot-app-next/client/vite.config.ts +++ b/e2e-chatbot-app-next/client/vite.config.ts @@ -1,13 +1,24 @@ 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/pdfjs-dist/build/pdf.worker.min.mjs', + dest: 'assets', + }, + ], + }), + ], resolve: { alias: { - '@': path.resolve(__dirname, './src') + '@': path.resolve(__dirname, './src'), }, }, server: { diff --git a/e2e-chatbot-app-next/package-lock.json b/e2e-chatbot-app-next/package-lock.json index 11314404..42f04838 100644 --- a/e2e-chatbot-app-next/package-lock.json +++ b/e2e-chatbot-app-next/package-lock.json @@ -26,10 +26,8 @@ "@types/node": "^22.8.6", "concurrently": "^8.2.2", "msw": "^2.11.6", - "rolldown-vite": "^7.3.1", "tsdown": "0.15.9", - "typescript": "^5.9.3", - "vite": "npm:rolldown-vite@^7.3.1" + "typescript": "^5.9.3" }, "engines": { "node": ">=18.0.0", @@ -58,8 +56,10 @@ "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", "react-router-dom": "^6.22.0", "react-syntax-highlighter": "^15.6.6", "sonner": "^1.5.0", @@ -83,7 +83,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": { @@ -1847,6 +1848,256 @@ "node": ">=18" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.89.tgz", + "integrity": "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.89", + "@napi-rs/canvas-darwin-arm64": "0.1.89", + "@napi-rs/canvas-darwin-x64": "0.1.89", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", + "@napi-rs/canvas-linux-arm64-musl": "0.1.89", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", + "@napi-rs/canvas-linux-x64-gnu": "0.1.89", + "@napi-rs/canvas-linux-x64-musl": "0.1.89", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", + "@napi-rs/canvas-win32-x64-msvc": "0.1.89" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.89.tgz", + "integrity": "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.89.tgz", + "integrity": "sha512-k29cR/Zl20WLYM7M8YePevRu2VQRaKcRedYr1V/8FFHkyIQ8kShEV+MPoPGi+znvmd17Eqjy2Pk2F2kpM2umVg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.89.tgz", + "integrity": "sha512-iUragqhBrA5FqU13pkhYBDbUD1WEAIlT8R2+fj6xHICY2nemzwMUI8OENDhRh7zuL06YDcRwENbjAVxOmaX9jg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.89.tgz", + "integrity": "sha512-y3SM9sfDWasY58ftoaI09YBFm35Ig8tosZqgahLJ2WGqawCusGNPV9P0/4PsrLOCZqGg629WxexQMY25n7zcvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.89.tgz", + "integrity": "sha512-NEoF9y8xq5fX8HG8aZunBom1ILdTwt7ayBzSBIwrmitk7snj4W6Fz/yN/ZOmlM1iyzHDNX5Xn0n+VgWCF8BEdA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.89.tgz", + "integrity": "sha512-UQQkIEzV12/l60j1ziMjZ+mtodICNUbrd205uAhbyTw0t60CrC/EsKb5/aJWGq1wM0agvcgZV72JJCKfLS6+4w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.89.tgz", + "integrity": "sha512-1/VmEoFaIO6ONeeEMGoWF17wOYZOl5hxDC1ios2Bkz/oQjbJJ8DY/X22vWTmvuUKWWhBVlo63pxLGZbjJU/heA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.89.tgz", + "integrity": "sha512-ebLuqkCuaPIkKgKH9q4+pqWi1tkPOfiTk5PM1LKR1tB9iO9sFNVSIgwEp+SJreTSbA2DK5rW8lQXiN78SjtcvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.89.tgz", + "integrity": "sha512-w+5qxHzplvA4BkHhCaizNMLLXiI+CfP84YhpHm/PqMub4u8J0uOAv+aaGv40rYEYra5hHRWr9LUd6cfW32o9/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.89.tgz", + "integrity": "sha512-DmyXa5lJHcjOsDC78BM3bnEECqbK3xASVMrKfvtT/7S7Z8NGQOugvu+L7b41V6cexCd34mBWgMOsjoEBceeB1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.89.tgz", + "integrity": "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -2807,6 +3058,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2824,6 +3076,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2841,6 +3094,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2858,6 +3112,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2875,6 +3130,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2892,6 +3148,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2909,6 +3166,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2926,6 +3184,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2943,6 +3202,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2960,6 +3220,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2974,6 +3235,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "^1.1.0" }, @@ -2994,6 +3256,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -3028,6 +3291,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4322,6 +4586,33 @@ "node": ">=14" } }, + "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", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "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": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -4371,6 +4662,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.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -4405,6 +4709,19 @@ "url": "https://opencollective.com/express" } }, + "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", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -6485,6 +6802,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.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -6673,6 +7003,19 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "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", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7483,6 +7826,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": "1.0.4", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", @@ -7493,6 +7849,16 @@ "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", @@ -7503,6 +7869,19 @@ "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": "1.0.4", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", @@ -7520,6 +7899,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", @@ -7974,6 +8363,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", @@ -8467,6 +8874,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.2", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", @@ -9306,6 +9730,16 @@ "dev": true, "license": "MIT" }, + "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": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9372,6 +9806,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.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -9436,6 +9883,18 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.4.530", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz", + "integrity": "sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.84" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9687,6 +10146,47 @@ "react": "^18.3.1" } }, + "node_modules/react-pdf": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz", + "integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==", + "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-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", @@ -10209,97 +10709,6 @@ } } }, - "node_modules/rolldown-vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.3.1.tgz", - "integrity": "sha512-LYzdNAjRHhF2yA4JUQm/QyARyi216N2rpJ0lJZb8E9FU2y5v6Vk+xq/U4XBOxMefpWixT5H3TslmAHm1rqIq2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/runtime": "0.101.0", - "fdir": "^6.5.0", - "lightningcss": "^1.30.2", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rolldown": "1.0.0-beta.53", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "esbuild": "^0.27.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/rolldown-vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/roughjs": { "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", @@ -10851,6 +11260,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", @@ -10897,6 +11312,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", @@ -11884,6 +12312,80 @@ } } }, + "node_modules/vite-plugin-static-copy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.2.0.tgz", + "integrity": "sha512-g2k9z8B/1Bx7D4wnFjPLx9dyYGrqWMLTpwTtPHhcU+ElNZP2O4+4OsyaficiDClus0dzVhdGvoGFYMJxoXZ12Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.4", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/sapphi-red" + }, + "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/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -11948,6 +12450,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 99d67cda..0ba24a78 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 @@ -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/config.ts b/e2e-chatbot-app-next/server/src/routes/config.ts index 213b1152..a778d1ed 100644 --- a/e2e-chatbot-app-next/server/src/routes/config.ts +++ b/e2e-chatbot-app-next/server/src/routes/config.ts @@ -5,6 +5,8 @@ import { type Router as RouterType, } from 'express'; import { isDatabaseAvailable } from '@chat-template/db'; +import { getCachedCliHost } from '@chat-template/auth'; +import { getHostUrl } from '@chat-template/utils'; export const configRouter: RouterType = Router(); @@ -13,9 +15,16 @@ export const configRouter: RouterType = Router(); * Returns feature flags based on environment configuration */ configRouter.get('/', (_req: Request, res: Response) => { + // Get Databricks workspace URL + let workspaceUrl = getCachedCliHost(); + if (!workspaceUrl) { + workspaceUrl = getHostUrl(); + } + res.json({ features: { chatHistory: isDatabaseAvailable(), }, + workspaceUrl, }); }); 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..5fdb9a0b --- /dev/null +++ b/e2e-chatbot-app-next/server/src/routes/files.ts @@ -0,0 +1,224 @@ +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); + +/** + * 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. + * + * 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.post( + '/databricks-file', + requireAuth, + async (req: Request, res: Response) => { + try { + 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' }); + } + + // 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) { + hostUrl = getHostUrl(); + } + + // Construct the Databricks Files API URL + // Path should start with /Volumes/ for Unity Catalog volumes + // See: https://docs.databricks.com/api/workspace/files/download + const normalizedPath = filePath.startsWith('/') + ? filePath + : `/${filePath}`; + + // 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}`); + + // 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(filePath); + + // 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 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(); + }); + + 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); + + // 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', + 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, +): Readable { + const reader = webStream.getReader(); + + return new Readable({ + async read() { + 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)); + }, + }); +} 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 8e80ffc4..b866c718 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__'; @@ -256,4 +252,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'); + }); +});