diff --git a/.changeset/weak-bobcats-trade.md b/.changeset/weak-bobcats-trade.md new file mode 100644 index 0000000000000000000000000000000000000000..82cf959de376078881cba185b68543e9bb98628a --- /dev/null +++ b/.changeset/weak-bobcats-trade.md @@ -0,0 +1,5 @@ +--- +"create-llama": patch +--- + +Display PDF files in source nodes diff --git a/templates/types/streaming/express/index.ts b/templates/types/streaming/express/index.ts index 150dbf598c909aa1e70200d458becfb2bbf34f28..5940c09d0b7531cbda98e6e77af7d0a74ba5c4fe 100644 --- a/templates/types/streaming/express/index.ts +++ b/templates/types/streaming/express/index.ts @@ -31,6 +31,7 @@ if (isDevelopment) { console.warn("Production CORS origin not set, defaulting to no CORS."); } +app.use("/api/data", express.static("data")); app.use(express.text()); app.get("/", (req: Request, res: Response) => { diff --git a/templates/types/streaming/fastapi/main.py b/templates/types/streaming/fastapi/main.py index 1a4e58bebc6cad3e95eca15fefa6d36496a609ac..c053fd6d28f9b12718f9e5562c3e5880ffb9ba98 100644 --- a/templates/types/streaming/fastapi/main.py +++ b/templates/types/streaming/fastapi/main.py @@ -11,6 +11,7 @@ from fastapi.responses import RedirectResponse from app.api.routers.chat import chat_router from app.settings import init_settings from app.observability import init_observability +from fastapi.staticfiles import StaticFiles app = FastAPI() @@ -20,7 +21,6 @@ init_observability() environment = os.getenv("ENVIRONMENT", "dev") # Default to 'development' if not set - if environment == "dev": logger = logging.getLogger("uvicorn") logger.warning("Running in development mode - allowing CORS for all origins") @@ -38,6 +38,8 @@ if environment == "dev": return RedirectResponse(url="/docs") +if os.path.exists("data"): + app.mount("/api/data", StaticFiles(directory="data"), name="static") app.include_router(chat_router, prefix="/api/chat") diff --git a/templates/types/streaming/nextjs/app/api/data/[path]/route.ts b/templates/types/streaming/nextjs/app/api/data/[path]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e5fb9271145501634fabd25aeb788ad487d022c --- /dev/null +++ b/templates/types/streaming/nextjs/app/api/data/[path]/route.ts @@ -0,0 +1,38 @@ +import { readFile } from "fs/promises"; +import { NextRequest, NextResponse } from "next/server"; +import path from "path"; + +/** + * This API is to get file data from ./data folder + * It receives path slug and response file data like serve static file + */ +export async function GET( + _request: NextRequest, + { params }: { params: { path: string } }, +) { + const slug = params.path; + + if (!slug) { + return NextResponse.json({ detail: "Missing file slug" }, { status: 400 }); + } + + if (slug.includes("..") || path.isAbsolute(slug)) { + return NextResponse.json({ detail: "Invalid file path" }, { status: 400 }); + } + + try { + const filePath = path.join(process.cwd(), "data", slug); + const blob = await readFile(filePath); + + return new NextResponse(blob, { + status: 200, + statusText: "OK", + headers: { + "Content-Length": blob.byteLength.toString(), + }, + }); + } catch (error) { + console.error(error); + return NextResponse.json({ detail: "File not found" }, { status: 404 }); + } +} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-sources.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-sources.tsx index de8c3edb023fe4a1174ada73572067d73041b2ac..a492eebc50ec094a961e8be949db73e807ca404e 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-sources.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-sources.tsx @@ -1,20 +1,78 @@ -import { ArrowUpRightSquare, Check, Copy } from "lucide-react"; +import { Check, Copy } from "lucide-react"; import { useMemo } from "react"; import { Button } from "../button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card"; +import { getStaticFileDataUrl } from "../lib/url"; import { SourceData, SourceNode } from "./index"; import { useCopyToClipboard } from "./use-copy-to-clipboard"; +import PdfDialog from "./widgets/PdfDialog"; -const SCORE_THRESHOLD = 0.5; +const SCORE_THRESHOLD = 0.3; + +function SourceNumberButton({ index }: { index: number }) { + return ( + <div className="text-xs w-5 h-5 rounded-full bg-gray-100 mb-2 flex items-center justify-center hover:text-white hover:bg-primary hover:cursor-pointer"> + {index + 1} + </div> + ); +} + +enum NODE_TYPE { + URL, + FILE, + UNKNOWN, +} + +type NodeInfo = { + id: string; + type: NODE_TYPE; + path?: string; + url?: string; +}; + +function getNodeInfo(node: SourceNode): NodeInfo { + if (typeof node.metadata["URL"] === "string") { + const url = node.metadata["URL"]; + return { + id: node.id, + type: NODE_TYPE.URL, + path: url, + url, + }; + } + if (typeof node.metadata["file_path"] === "string") { + const fileName = node.metadata["file_name"] as string; + return { + id: node.id, + type: NODE_TYPE.FILE, + path: node.metadata["file_path"], + url: getStaticFileDataUrl(fileName), + }; + } + + return { + id: node.id, + type: NODE_TYPE.UNKNOWN, + }; +} export function ChatSources({ data }: { data: SourceData }) { - const sources = useMemo(() => { - return ( - data.nodes - ?.filter((node) => Object.keys(node.metadata).length > 0) - ?.filter((node) => (node.score ?? 1) > SCORE_THRESHOLD) - .sort((a, b) => (b.score ?? 1) - (a.score ?? 1)) || [] - ); + const sources: NodeInfo[] = useMemo(() => { + // aggregate nodes by url or file_path (get the highest one by score) + const nodesByPath: { [path: string]: NodeInfo } = {}; + + data.nodes + .filter((node) => (node.score ?? 1) > SCORE_THRESHOLD) + .sort((a, b) => (b.score ?? 1) - (a.score ?? 1)) + .forEach((node) => { + const nodeInfo = getNodeInfo(node); + const key = nodeInfo.path ?? nodeInfo.id; // use id as key for UNKNOWN type + if (!nodesByPath[key]) { + nodesByPath[key] = nodeInfo; + } + }); + + return Object.values(nodesByPath); }, [data.nodes]); if (sources.length === 0) return null; @@ -23,55 +81,52 @@ export function ChatSources({ data }: { data: SourceData }) { <div className="space-x-2 text-sm"> <span className="font-semibold">Sources:</span> <div className="inline-flex gap-1 items-center"> - {sources.map((node: SourceNode, index: number) => ( - <div key={node.id}> - <HoverCard> - <HoverCardTrigger> - <div className="text-xs w-5 h-5 rounded-full bg-gray-100 mb-2 flex items-center justify-center hover:text-white hover:bg-primary hover:cursor-pointer"> - {index + 1} - </div> - </HoverCardTrigger> - <HoverCardContent> - <NodeInfo node={node} /> - </HoverCardContent> - </HoverCard> - </div> - ))} + {sources.map((nodeInfo: NodeInfo, index: number) => { + if (nodeInfo.path?.endsWith(".pdf")) { + return ( + <PdfDialog + key={nodeInfo.id} + documentId={nodeInfo.id} + url={nodeInfo.url!} + path={nodeInfo.path} + trigger={<SourceNumberButton index={index} />} + /> + ); + } + return ( + <div key={nodeInfo.id}> + <HoverCard> + <HoverCardTrigger> + <SourceNumberButton index={index} /> + </HoverCardTrigger> + <HoverCardContent className="w-[320px]"> + <NodeInfo nodeInfo={nodeInfo} /> + </HoverCardContent> + </HoverCard> + </div> + ); + })} </div> </div> ); } -function NodeInfo({ node }: { node: SourceNode }) { +function NodeInfo({ nodeInfo }: { nodeInfo: NodeInfo }) { const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 }); - if (typeof node.metadata["URL"] === "string") { - // this is a node generated by the web loader, it contains an external URL - // add a link to view this URL - return ( - <a - className="space-x-2 flex items-center my-2 hover:text-blue-900" - href={node.metadata["URL"]} - target="_blank" - > - <span>{node.metadata["URL"]}</span> - <ArrowUpRightSquare className="w-4 h-4" /> - </a> - ); - } - - if (typeof node.metadata["file_path"] === "string") { - // this is a node generated by the file loader, it contains file path - // add a button to copy the path to the clipboard - const filePath = node.metadata["file_path"]; + if (nodeInfo.type !== NODE_TYPE.UNKNOWN) { + // this is a node generated by the web loader or file loader, + // add a link to view its URL and a button to copy the URL to the clipboard return ( - <div className="flex items-center px-2 py-1 justify-between my-2"> - <span>{filePath}</span> + <div className="flex items-center my-2"> + <a className="hover:text-blue-900" href={nodeInfo.url} target="_blank"> + <span>{nodeInfo.path}</span> + </a> <Button - onClick={() => copyToClipboard(filePath)} + onClick={() => copyToClipboard(nodeInfo.path!)} size="icon" variant="ghost" - className="h-12 w-12" + className="h-12 w-12 shrink-0" > {isCopied ? ( <Check className="h-4 w-4" /> @@ -84,7 +139,6 @@ function NodeInfo({ node }: { node: SourceNode }) { } // node generated by unknown loader, implement renderer by analyzing logged out metadata - console.log("Node metadata", node.metadata); return ( <p> Sorry, unknown node type. Please add a new renderer in the NodeInfo diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/widgets/PdfDialog.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/widgets/PdfDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..00274546c2132ac9e4a3f76b1f827fc129abb664 --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/chat/widgets/PdfDialog.tsx @@ -0,0 +1,56 @@ +import { PDFViewer, PdfFocusProvider } from "@llamaindex/pdf-viewer"; +import { Button } from "../../button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "../../drawer"; + +export interface PdfDialogProps { + documentId: string; + path: string; + url: string; + trigger: React.ReactNode; +} + +export default function PdfDialog(props: PdfDialogProps) { + return ( + <Drawer direction="left"> + <DrawerTrigger>{props.trigger}</DrawerTrigger> + <DrawerContent className="w-3/5 mt-24 h-full max-h-[96%] "> + <DrawerHeader className="flex justify-between"> + <div className="space-y-2"> + <DrawerTitle>PDF Content</DrawerTitle> + <DrawerDescription> + File path:{" "} + <a + className="hover:text-blue-900" + href={props.url} + target="_blank" + > + {props.path} + </a> + </DrawerDescription> + </div> + <DrawerClose asChild> + <Button variant="outline">Close</Button> + </DrawerClose> + </DrawerHeader> + <div className="m-4"> + <PdfFocusProvider> + <PDFViewer + file={{ + id: props.documentId, + url: props.url, + }} + /> + </PdfFocusProvider> + </div> + </DrawerContent> + </Drawer> + ); +} diff --git a/templates/types/streaming/nextjs/app/components/ui/drawer.tsx b/templates/types/streaming/nextjs/app/components/ui/drawer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf733c88522a1ad9392fea1ddef177d01b0fafd3 --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "./lib/utils"; + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( + <DrawerPrimitive.Root + shouldScaleBackground={shouldScaleBackground} + {...props} + /> +); +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <DrawerPrimitive.Overlay + ref={ref} + className={cn("fixed inset-0 z-50 bg-black/80", className)} + {...props} + /> +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <DrawerPortal> + <DrawerOverlay /> + <DrawerPrimitive.Content + ref={ref} + className={cn( + "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", + className, + )} + {...props} + > + <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> + {children} + </DrawerPrimitive.Content> + </DrawerPortal> +)); +DrawerContent.displayName = "DrawerContent"; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} + {...props} + /> +); +DrawerHeader.displayName = "DrawerHeader"; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn("mt-auto flex flex-col gap-2 p-4", className)} + {...props} + /> +); +DrawerFooter.displayName = "DrawerFooter"; + +const DrawerTitle = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> +>(({ className, ...props }, ref) => ( + <DrawerPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className, + )} + {...props} + /> +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> +>(({ className, ...props }, ref) => ( + <DrawerPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + DrawerPortal, + DrawerTitle, + DrawerTrigger, +}; diff --git a/templates/types/streaming/nextjs/app/components/ui/lib/url.ts b/templates/types/streaming/nextjs/app/components/ui/lib/url.ts new file mode 100644 index 0000000000000000000000000000000000000000..90236246c83e3f9b5ff18d9e3af98d263de94d4a --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/lib/url.ts @@ -0,0 +1,11 @@ +const STORAGE_FOLDER = "data"; + +export const getStaticFileDataUrl = (filename: string) => { + const isUsingBackend = !!process.env.NEXT_PUBLIC_CHAT_API; + const fileUrl = `/api/${STORAGE_FOLDER}/${filename}`; + if (isUsingBackend) { + const backendOrigin = new URL(process.env.NEXT_PUBLIC_CHAT_API!).origin; + return `${backendOrigin}/${fileUrl}`; + } + return fileUrl; +}; diff --git a/templates/types/streaming/nextjs/package.json b/templates/types/streaming/nextjs/package.json index 182828a4dc436d0f55802a7a243bb8a250022d7b..babe431916ee0db3705d25450cdabdff11654ebf 100644 --- a/templates/types/streaming/nextjs/package.json +++ b/templates/types/streaming/nextjs/package.json @@ -32,7 +32,9 @@ "remark-math": "^5.1.1", "rehype-katex": "^7.0.0", "supports-color": "^8.1.1", - "tailwind-merge": "^2.1.0" + "tailwind-merge": "^2.1.0", + "vaul": "^0.9.1", + "@llamaindex/pdf-viewer": "^1.1.1" }, "devDependencies": { "@types/node": "^20.10.3",