Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/weak-bobcats-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"create-llama": patch
---

Display PDF files in source nodes
1 change: 1 addition & 0 deletions templates/types/streaming/express/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion templates/types/streaming/fastapi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move the import statement to the top of the file to follow best practices.

- from fastapi.staticfiles import StaticFiles
+ from fastapi.staticfiles import StaticFiles

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
from fastapi.staticfiles import StaticFiles
from fastapi.staticfiles import StaticFiles



app = FastAPI()
Expand All @@ -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")
Expand All @@ -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")
Comment on lines +41 to +42
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

Codebase Verification

The data directory is missing or not readable, which will prevent the static file serving configuration from working as intended.

  • Ensure the data directory exists in the root of the repository.
  • Verify that the directory has the correct read permissions.
Analysis chain

Ensure the data directory exists and has the correct permissions for serving static files.

Scripts Executed

The following scripts were executed for the analysis:


Script:

#!/bin/bash
# Description: Verify the existence and permissions of the `data` directory.

# Test: Check if the `data` directory exists and has read permissions.
if [ -d "data" ] && [ -r "data" ]; then
  echo "The 'data' directory exists and is readable."
else
  echo "The 'data' directory is missing or not readable."
  exit 1
fi

Length of output: 139

app.include_router(chat_router, prefix="/api/chat")


Expand Down
38 changes: 38 additions & 0 deletions templates/types/streaming/nextjs/app/api/data/[path]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
150 changes: 102 additions & 48 deletions templates/types/streaming/nextjs/app/components/ui/chat/chat-sources.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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" />
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading