diff --git a/.changeset/weak-bobcats-trade.md b/.changeset/weak-bobcats-trade.md new file mode 100644 index 000000000..82cf959de --- /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 150dbf598..5940c09d0 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 1a4e58beb..c053fd6d2 100644 --- a/templates/types/streaming/fastapi/main.py +++ b/templates/types/streaming/fastapi/main.py @@ -11,6 +11,7 @@ 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 @@ 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 @@ async def redirect_to_docs(): 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 000000000..8e5fb9271 --- /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 de8c3edb0..a492eebc5 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 ( +
+ {index + 1} +
+ ); +} + +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 }) {
Sources:
- {sources.map((node: SourceNode, index: number) => ( -
- - -
- {index + 1} -
-
- - - -
-
- ))} + {sources.map((nodeInfo: NodeInfo, index: number) => { + if (nodeInfo.path?.endsWith(".pdf")) { + return ( + } + /> + ); + } + return ( +
+ + + + + + + + +
+ ); + })}
); } -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 ( - - {node.metadata["URL"]} - - - ); - } - - 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 ( -
- {filePath} +
+ + {nodeInfo.path} + + + +
+ + + +
+ + + ); +} 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 000000000..bf733c885 --- /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) => ( + +); +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = "DrawerContent"; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = "DrawerHeader"; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = "DrawerFooter"; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +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 000000000..90236246c --- /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 3093201b0..73550d0d1 100644 --- a/templates/types/streaming/nextjs/package.json +++ b/templates/types/streaming/nextjs/package.json @@ -31,7 +31,9 @@ "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", "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",