Skip to content

Commit 56537a1

Browse files
authored
feat: host local files and add viewer for PDFs (#85)
1 parent d8dfc29 commit 56537a1

File tree

9 files changed

+337
-50
lines changed

9 files changed

+337
-50
lines changed

.changeset/weak-bobcats-trade.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"create-llama": patch
3+
---
4+
5+
Display PDF files in source nodes

templates/types/streaming/express/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ if (isDevelopment) {
3131
console.warn("Production CORS origin not set, defaulting to no CORS.");
3232
}
3333

34+
app.use("/api/data", express.static("data"));
3435
app.use(express.text());
3536

3637
app.get("/", (req: Request, res: Response) => {

templates/types/streaming/fastapi/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from app.api.routers.chat import chat_router
1212
from app.settings import init_settings
1313
from app.observability import init_observability
14+
from fastapi.staticfiles import StaticFiles
1415

1516

1617
app = FastAPI()
@@ -20,7 +21,6 @@
2021

2122
environment = os.getenv("ENVIRONMENT", "dev") # Default to 'development' if not set
2223

23-
2424
if environment == "dev":
2525
logger = logging.getLogger("uvicorn")
2626
logger.warning("Running in development mode - allowing CORS for all origins")
@@ -38,6 +38,8 @@ async def redirect_to_docs():
3838
return RedirectResponse(url="/docs")
3939

4040

41+
if os.path.exists("data"):
42+
app.mount("/api/data", StaticFiles(directory="data"), name="static")
4143
app.include_router(chat_router, prefix="/api/chat")
4244

4345

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { readFile } from "fs/promises";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import path from "path";
4+
5+
/**
6+
* This API is to get file data from ./data folder
7+
* It receives path slug and response file data like serve static file
8+
*/
9+
export async function GET(
10+
_request: NextRequest,
11+
{ params }: { params: { path: string } },
12+
) {
13+
const slug = params.path;
14+
15+
if (!slug) {
16+
return NextResponse.json({ detail: "Missing file slug" }, { status: 400 });
17+
}
18+
19+
if (slug.includes("..") || path.isAbsolute(slug)) {
20+
return NextResponse.json({ detail: "Invalid file path" }, { status: 400 });
21+
}
22+
23+
try {
24+
const filePath = path.join(process.cwd(), "data", slug);
25+
const blob = await readFile(filePath);
26+
27+
return new NextResponse(blob, {
28+
status: 200,
29+
statusText: "OK",
30+
headers: {
31+
"Content-Length": blob.byteLength.toString(),
32+
},
33+
});
34+
} catch (error) {
35+
console.error(error);
36+
return NextResponse.json({ detail: "File not found" }, { status: 404 });
37+
}
38+
}

templates/types/streaming/nextjs/app/components/ui/chat/chat-sources.tsx

Lines changed: 102 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,78 @@
1-
import { ArrowUpRightSquare, Check, Copy } from "lucide-react";
1+
import { Check, Copy } from "lucide-react";
22
import { useMemo } from "react";
33
import { Button } from "../button";
44
import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card";
5+
import { getStaticFileDataUrl } from "../lib/url";
56
import { SourceData, SourceNode } from "./index";
67
import { useCopyToClipboard } from "./use-copy-to-clipboard";
8+
import PdfDialog from "./widgets/PdfDialog";
79

8-
const SCORE_THRESHOLD = 0.5;
10+
const SCORE_THRESHOLD = 0.3;
11+
12+
function SourceNumberButton({ index }: { index: number }) {
13+
return (
14+
<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">
15+
{index + 1}
16+
</div>
17+
);
18+
}
19+
20+
enum NODE_TYPE {
21+
URL,
22+
FILE,
23+
UNKNOWN,
24+
}
25+
26+
type NodeInfo = {
27+
id: string;
28+
type: NODE_TYPE;
29+
path?: string;
30+
url?: string;
31+
};
32+
33+
function getNodeInfo(node: SourceNode): NodeInfo {
34+
if (typeof node.metadata["URL"] === "string") {
35+
const url = node.metadata["URL"];
36+
return {
37+
id: node.id,
38+
type: NODE_TYPE.URL,
39+
path: url,
40+
url,
41+
};
42+
}
43+
if (typeof node.metadata["file_path"] === "string") {
44+
const fileName = node.metadata["file_name"] as string;
45+
return {
46+
id: node.id,
47+
type: NODE_TYPE.FILE,
48+
path: node.metadata["file_path"],
49+
url: getStaticFileDataUrl(fileName),
50+
};
51+
}
52+
53+
return {
54+
id: node.id,
55+
type: NODE_TYPE.UNKNOWN,
56+
};
57+
}
958

1059
export function ChatSources({ data }: { data: SourceData }) {
11-
const sources = useMemo(() => {
12-
return (
13-
data.nodes
14-
?.filter((node) => Object.keys(node.metadata).length > 0)
15-
?.filter((node) => (node.score ?? 1) > SCORE_THRESHOLD)
16-
.sort((a, b) => (b.score ?? 1) - (a.score ?? 1)) || []
17-
);
60+
const sources: NodeInfo[] = useMemo(() => {
61+
// aggregate nodes by url or file_path (get the highest one by score)
62+
const nodesByPath: { [path: string]: NodeInfo } = {};
63+
64+
data.nodes
65+
.filter((node) => (node.score ?? 1) > SCORE_THRESHOLD)
66+
.sort((a, b) => (b.score ?? 1) - (a.score ?? 1))
67+
.forEach((node) => {
68+
const nodeInfo = getNodeInfo(node);
69+
const key = nodeInfo.path ?? nodeInfo.id; // use id as key for UNKNOWN type
70+
if (!nodesByPath[key]) {
71+
nodesByPath[key] = nodeInfo;
72+
}
73+
});
74+
75+
return Object.values(nodesByPath);
1876
}, [data.nodes]);
1977

2078
if (sources.length === 0) return null;
@@ -23,55 +81,52 @@ export function ChatSources({ data }: { data: SourceData }) {
2381
<div className="space-x-2 text-sm">
2482
<span className="font-semibold">Sources:</span>
2583
<div className="inline-flex gap-1 items-center">
26-
{sources.map((node: SourceNode, index: number) => (
27-
<div key={node.id}>
28-
<HoverCard>
29-
<HoverCardTrigger>
30-
<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">
31-
{index + 1}
32-
</div>
33-
</HoverCardTrigger>
34-
<HoverCardContent>
35-
<NodeInfo node={node} />
36-
</HoverCardContent>
37-
</HoverCard>
38-
</div>
39-
))}
84+
{sources.map((nodeInfo: NodeInfo, index: number) => {
85+
if (nodeInfo.path?.endsWith(".pdf")) {
86+
return (
87+
<PdfDialog
88+
key={nodeInfo.id}
89+
documentId={nodeInfo.id}
90+
url={nodeInfo.url!}
91+
path={nodeInfo.path}
92+
trigger={<SourceNumberButton index={index} />}
93+
/>
94+
);
95+
}
96+
return (
97+
<div key={nodeInfo.id}>
98+
<HoverCard>
99+
<HoverCardTrigger>
100+
<SourceNumberButton index={index} />
101+
</HoverCardTrigger>
102+
<HoverCardContent className="w-[320px]">
103+
<NodeInfo nodeInfo={nodeInfo} />
104+
</HoverCardContent>
105+
</HoverCard>
106+
</div>
107+
);
108+
})}
40109
</div>
41110
</div>
42111
);
43112
}
44113

45-
function NodeInfo({ node }: { node: SourceNode }) {
114+
function NodeInfo({ nodeInfo }: { nodeInfo: NodeInfo }) {
46115
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 });
47116

48-
if (typeof node.metadata["URL"] === "string") {
49-
// this is a node generated by the web loader, it contains an external URL
50-
// add a link to view this URL
51-
return (
52-
<a
53-
className="space-x-2 flex items-center my-2 hover:text-blue-900"
54-
href={node.metadata["URL"]}
55-
target="_blank"
56-
>
57-
<span>{node.metadata["URL"]}</span>
58-
<ArrowUpRightSquare className="w-4 h-4" />
59-
</a>
60-
);
61-
}
62-
63-
if (typeof node.metadata["file_path"] === "string") {
64-
// this is a node generated by the file loader, it contains file path
65-
// add a button to copy the path to the clipboard
66-
const filePath = node.metadata["file_path"];
117+
if (nodeInfo.type !== NODE_TYPE.UNKNOWN) {
118+
// this is a node generated by the web loader or file loader,
119+
// add a link to view its URL and a button to copy the URL to the clipboard
67120
return (
68-
<div className="flex items-center px-2 py-1 justify-between my-2">
69-
<span>{filePath}</span>
121+
<div className="flex items-center my-2">
122+
<a className="hover:text-blue-900" href={nodeInfo.url} target="_blank">
123+
<span>{nodeInfo.path}</span>
124+
</a>
70125
<Button
71-
onClick={() => copyToClipboard(filePath)}
126+
onClick={() => copyToClipboard(nodeInfo.path!)}
72127
size="icon"
73128
variant="ghost"
74-
className="h-12 w-12"
129+
className="h-12 w-12 shrink-0"
75130
>
76131
{isCopied ? (
77132
<Check className="h-4 w-4" />
@@ -84,7 +139,6 @@ function NodeInfo({ node }: { node: SourceNode }) {
84139
}
85140

86141
// node generated by unknown loader, implement renderer by analyzing logged out metadata
87-
console.log("Node metadata", node.metadata);
88142
return (
89143
<p>
90144
Sorry, unknown node type. Please add a new renderer in the NodeInfo
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { PDFViewer, PdfFocusProvider } from "@llamaindex/pdf-viewer";
2+
import { Button } from "../../button";
3+
import {
4+
Drawer,
5+
DrawerClose,
6+
DrawerContent,
7+
DrawerDescription,
8+
DrawerHeader,
9+
DrawerTitle,
10+
DrawerTrigger,
11+
} from "../../drawer";
12+
13+
export interface PdfDialogProps {
14+
documentId: string;
15+
path: string;
16+
url: string;
17+
trigger: React.ReactNode;
18+
}
19+
20+
export default function PdfDialog(props: PdfDialogProps) {
21+
return (
22+
<Drawer direction="left">
23+
<DrawerTrigger>{props.trigger}</DrawerTrigger>
24+
<DrawerContent className="w-3/5 mt-24 h-full max-h-[96%] ">
25+
<DrawerHeader className="flex justify-between">
26+
<div className="space-y-2">
27+
<DrawerTitle>PDF Content</DrawerTitle>
28+
<DrawerDescription>
29+
File path:{" "}
30+
<a
31+
className="hover:text-blue-900"
32+
href={props.url}
33+
target="_blank"
34+
>
35+
{props.path}
36+
</a>
37+
</DrawerDescription>
38+
</div>
39+
<DrawerClose asChild>
40+
<Button variant="outline">Close</Button>
41+
</DrawerClose>
42+
</DrawerHeader>
43+
<div className="m-4">
44+
<PdfFocusProvider>
45+
<PDFViewer
46+
file={{
47+
id: props.documentId,
48+
url: props.url,
49+
}}
50+
/>
51+
</PdfFocusProvider>
52+
</div>
53+
</DrawerContent>
54+
</Drawer>
55+
);
56+
}

0 commit comments

Comments
 (0)