├── .prettierrc.json
├── .eslintrc.json
├── bun.lockb
├── postcss.config.js
├── app
├── page.tsx
├── layout.tsx
├── globals.css
└── actions.ts
├── lib
└── utils.ts
├── components
├── SubmitButton.tsx
├── ui
│ ├── label.tsx
│ ├── input.tsx
│ └── button.tsx
├── Preview.tsx
├── FilePicker.tsx
├── Home.tsx
└── ChatWindow.tsx
├── components.json
├── .gitignore
├── tailwind.config.js
├── tsconfig.json
├── next.config.js
├── package.json
├── tailwind.config.ts
└── README.md
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/playground/local-pdf-ai/master/bun.lockb
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import HomePage from "@/components/Home";
2 |
3 | export default function Home() {
4 | return (
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/components/SubmitButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { experimental_useFormStatus as useFormStatus } from "react-dom"
4 |
5 | export function SubmitButton(props: { label: string }) {
6 | const { pending } = useFormStatus()
7 |
8 | return (
9 |
10 | {props.label}
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | .yarn
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
5 | './components/**/*.{js,ts,jsx,tsx,mdx}',
6 | './app/**/*.{js,ts,jsx,tsx,mdx}',
7 | ],
8 | theme: {
9 | extend: {
10 | backgroundImage: {
11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
12 | 'gradient-conic':
13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
14 | },
15 | },
16 | },
17 | plugins: [],
18 | }
19 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import { Public_Sans } from "next/font/google";
3 |
4 | const publicSans = Public_Sans({ subsets: ["latin"] });
5 |
6 | export default function RootLayout({
7 | children,
8 | }: {
9 | children: React.ReactNode;
10 | }) {
11 | return (
12 |
13 |
14 | Local PDF AI
15 |
16 |
17 | {children}
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | serverActions: true,
5 | },
6 | output: 'export',
7 | webpack: (config, { isServer }) => {
8 | config.resolve.alias = {
9 | ...config.resolve.alias,
10 | "sharp$": false,
11 | "onnxruntime-node$": false,
12 | }
13 | config.experiments = {
14 | ...config.experiments,
15 | topLevelAwait: true,
16 | asyncWebAssembly: true,
17 | };
18 | config.module.rules.push({
19 | test: /\.md$/i,
20 | use: "raw-loader",
21 | });
22 | if (!isServer) {
23 | config.resolve.fallback = {
24 | fs: false,
25 | "node:fs/promises": false,
26 | assert: false,
27 | module: false,
28 | perf_hooks: false,
29 | };
30 | }
31 | return config;
32 | },
33 | }
34 |
35 | module.exports = nextConfig
36 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/components/Preview.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useEffect } from "react"
4 | import * as pdfobject from "pdfobject"
5 |
6 | interface PreviewProps {
7 | fileToPreview: File
8 | page?: number
9 | }
10 |
11 | const Preview: React.FC = ({
12 | fileToPreview,
13 | page
14 | }) => {
15 |
16 | const [b64String, setb64String] = useState(null)
17 |
18 |
19 | // useEffect(() => {
20 | // console.log(b64String)
21 | // }, [b64String])
22 | useEffect(() => {
23 | const options = {
24 | title: fileToPreview.name,
25 | pdfOpenParams: {
26 | view: "fitH",
27 | page: page || 1,
28 | zoom: "scale,left,top",
29 | pageMode: 'none'
30 | }
31 | }
32 | console.log(`Page: ${page}`)
33 | const reader = new FileReader()
34 | reader.onload = () => {
35 | setb64String(reader.result as string);
36 | }
37 | reader.readAsDataURL(fileToPreview)
38 | pdfobject.embed(b64String as string, "#pdfobject", options)
39 | }, [page, b64String])
40 |
41 | return (
42 |
43 |
44 | )
45 | }
46 |
47 | export default Preview
48 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "local-pdf-ai",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "format": "prettier --write \"app\""
11 | },
12 | "engines": {
13 | "node": ">=18"
14 | },
15 | "dependencies": {
16 | "@radix-ui/react-label": "^2.0.2",
17 | "@radix-ui/react-slot": "^1.0.2",
18 | "@types/node": "20.4.5",
19 | "@types/react": "18.2.17",
20 | "@types/react-dom": "18.2.7",
21 | "autoprefixer": "10.4.14",
22 | "class-variance-authority": "^0.7.0",
23 | "clsx": "^2.1.0",
24 | "encoding": "^0.1.13",
25 | "eslint": "8.46.0",
26 | "eslint-config-next": "13.4.12",
27 | "langchain": "^0.1.28",
28 | "llamaindex": "^0.2.1",
29 | "lucide-react": "^0.363.0",
30 | "next": "13.4.12",
31 | "pdf-parse": "^1.1.1",
32 | "pdfobject": "^2.3.0",
33 | "postcss": "8.4.27",
34 | "react": "18.2.0",
35 | "react-dom": "18.2.0",
36 | "tailwind-merge": "^2.2.2",
37 | "tailwindcss": "3.3.3",
38 | "tailwindcss-animate": "^1.0.7",
39 | "typescript": "5.1.6"
40 | },
41 | "devDependencies": {
42 | "prettier": "3.0.0"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/components/FilePicker.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Input } from "@/components/ui/input"
4 | import { Label } from "@/components/ui/label"
5 | import { Dispatch, SetStateAction, useState, DragEvent } from "react"
6 |
7 | interface FilePickerProps {
8 | setSelectedFile: Dispatch>
9 | setPage: Dispatch>
10 | }
11 |
12 | const FilePicker: React.FC = ({
13 | setSelectedFile,
14 | setPage
15 | }) => {
16 | const [status, setStatus] = useState("");
17 |
18 | const handleFileDrop = (e: DragEvent) => {
19 | e.preventDefault()
20 | const file: File = e.dataTransfer.files[0]
21 | if (file.type == 'application/pdf') {
22 | setSelectedFile(file)
23 | setPage(1)
24 | } else {
25 | setStatus("Drop PDFs only")
26 | }
27 | }
28 |
29 | return (
30 |
32 |
33 | Select PDF to chat
34 |
35 |
setStatus("Drop PDF file to chat")}
37 | onDragLeave={() => setStatus("")}
38 | onDrop={handleFileDrop}
39 | id="pdf"
40 | type="file"
41 | accept='.pdf'
42 | className="cursor-pointer"
43 | onChange={(e) => {
44 | if (e.target.files) {
45 | setSelectedFile(e.target.files[0])
46 | setPage(1)
47 | }
48 | }}
49 | />
50 |
{status}
51 |
52 | )
53 | }
54 |
55 | export default FilePicker
56 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 222.2 84% 4.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 84% 4.9%;
15 |
16 | --primary: 222.2 47.4% 11.2%;
17 | --primary-foreground: 210 40% 98%;
18 |
19 | --secondary: 210 40% 96.1%;
20 | --secondary-foreground: 222.2 47.4% 11.2%;
21 |
22 | --muted: 210 40% 96.1%;
23 | --muted-foreground: 215.4 16.3% 46.9%;
24 |
25 | --accent: 210 40% 96.1%;
26 | --accent-foreground: 222.2 47.4% 11.2%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 210 40% 98%;
30 |
31 | --border: 214.3 31.8% 91.4%;
32 | --input: 214.3 31.8% 91.4%;
33 | --ring: 222.2 84% 4.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 222.2 84% 4.9%;
40 | --foreground: 210 40% 98%;
41 |
42 | --card: 222.2 84% 4.9%;
43 | --card-foreground: 210 40% 98%;
44 |
45 | --popover: 222.2 84% 4.9%;
46 | --popover-foreground: 210 40% 98%;
47 |
48 | --primary: 210 40% 98%;
49 | --primary-foreground: 222.2 47.4% 11.2%;
50 |
51 | --secondary: 217.2 32.6% 17.5%;
52 | --secondary-foreground: 210 40% 98%;
53 |
54 | --muted: 217.2 32.6% 17.5%;
55 | --muted-foreground: 215 20.2% 65.1%;
56 |
57 | --accent: 217.2 32.6% 17.5%;
58 | --accent-foreground: 210 40% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 210 40% 98%;
62 |
63 | --border: 217.2 32.6% 17.5%;
64 | --input: 217.2 32.6% 17.5%;
65 | --ring: 212.7 26.8% 83.9%;
66 | }
67 | }
68 |
69 | /* @layer base { */
70 | /* * { */
71 | /* @apply border-border; */
72 | /* } */
73 | /* body { */
74 | /* @apply bg-background text-foreground; */
75 | /* } */
76 | /* } */
77 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/app/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { Document } from "llamaindex/Node"
4 | import { VectorStoreIndex } from "llamaindex/indices/vectorStore/index"
5 | import { ContextChatEngine } from "llamaindex/engines/chat/ContextChatEngine"
6 | import { OllamaEmbedding } from "llamaindex/embeddings/OllamaEmbedding"
7 | import { serviceContextFromDefaults } from "llamaindex/ServiceContext"
8 | import { Ollama } from "llamaindex/llm/ollama"
9 |
10 | interface LCDoc {
11 | pageContent: string,
12 | metadata: any,
13 | }
14 |
15 | const embedModel = new OllamaEmbedding({
16 | model: 'nomic-embed-text'
17 | })
18 |
19 | const llm = new Ollama({
20 | model: "phi",
21 | // model: "gemma",
22 | modelMetadata: {
23 | temperature: 0,
24 | maxTokens: 25,
25 | }
26 | })
27 |
28 | let chatEngine: ContextChatEngine | null = null;
29 |
30 | export async function processDocs(lcDocs: LCDoc[]) {
31 | if (lcDocs.length == 0) return;
32 | const docs = lcDocs.map(lcDoc => new Document({
33 | text: lcDoc.pageContent,
34 | metadata: lcDoc.metadata
35 | }))
36 |
37 | // console.log(docs)
38 | const index = await VectorStoreIndex.fromDocuments(docs, {
39 | serviceContext: serviceContextFromDefaults({
40 | chunkSize: 300,
41 | chunkOverlap: 20,
42 | embedModel, llm
43 | })
44 | })
45 | const retriever = index.asRetriever({
46 | similarityTopK: 2,
47 | })
48 | if (chatEngine) {
49 | chatEngine.reset()
50 | }
51 | chatEngine = new ContextChatEngine({
52 | retriever,
53 | chatModel: llm
54 | })
55 | // console.log("Done creating index with the new PDF")
56 | }
57 |
58 |
59 | export async function chat(query: string) {
60 | if (chatEngine) {
61 | const queryResult = await chatEngine.chat({
62 | message: query
63 | })
64 | const response = queryResult.response
65 | const metadata = queryResult.sourceNodes?.map(node => node.metadata)
66 | // const nodesText = queryResult.sourceNodes?.map(node => node.getContent(MetadataMode.LLM))
67 | return { response, metadata };
68 | }
69 | }
70 |
71 | export async function resetChatEngine() {
72 | if (chatEngine) chatEngine.reset();
73 | }
74 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/components/Home.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useEffect } from "react"
4 | import FilePicker from "@/components/FilePicker";
5 | import ChatWindow from '@/components/ChatWindow';
6 | import Preview from '@/components/Preview';
7 | import { WebPDFLoader } from "langchain/document_loaders/web/pdf"
8 |
9 | import { processDocs, chat } from "@/app/actions";
10 | import { ChatMessage } from "@/components/ChatWindow";
11 |
12 |
13 | export default function HomePage() {
14 |
15 | const [page, setPage] = useState(1)
16 | const [selectedFile, setSelectedFile] = useState(null);
17 | const [isLoading, setIsLoading] = useState(false)
18 | const [loadingMessage, setLoadingMessage] = useState("")
19 |
20 | const [messages, setMessages] = useState([])
21 |
22 | const startChat = async (input: string) => {
23 | setLoadingMessage("Thinking...")
24 | setIsLoading(true)
25 | try {
26 | setMessages([...messages, { role: 'human', statement: input },])
27 | const { response, metadata } = await chat(input);
28 | setMessages(
29 | [
30 | ...messages,
31 | { role: 'human', statement: input },
32 | { role: 'ai', statement: response }
33 | ]
34 | )
35 | // console.log(metadata)
36 | if (metadata.length > 0) {
37 | setPage(metadata[0].loc.pageNumber)
38 | }
39 | setLoadingMessage("Got response from AI.")
40 | } catch (e) {
41 | console.log(e)
42 | setLoadingMessage("Error generating response.")
43 | } finally {
44 | setIsLoading(false)
45 | }
46 | }
47 |
48 |
49 | useEffect(() => {
50 | setLoadingMessage("Creating Index from the PDF...")
51 | setIsLoading(true);
52 | const processPdfAsync = async () => {
53 | if (selectedFile) {
54 | const loader = new WebPDFLoader(
55 | selectedFile,
56 | { parsedItemSeparator: " " }
57 | );
58 | const lcDocs = (await loader.load()).map(lcDoc => ({
59 | pageContent: lcDoc.pageContent,
60 | metadata: lcDoc.metadata,
61 | }))
62 | try {
63 | await processDocs(lcDocs)
64 | setLoadingMessage("Done creating Index from the PDF.")
65 | } catch (e) {
66 | console.log(e)
67 | setLoadingMessage("Error while creating index")
68 | } finally {
69 | setIsLoading(false);
70 | }
71 | }
72 | }
73 | processPdfAsync()
74 | // console.log(selectedFile)
75 | }, [selectedFile])
76 |
77 | return (
78 |
79 | {selectedFile ? (
80 |
92 | ) : (
93 |
96 | )}
97 |
98 | )
99 | }
100 |
--------------------------------------------------------------------------------
/components/ChatWindow.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Dispatch, SetStateAction, useState } from "react";
4 | import { CircleX, LoaderCircle, Trash } from 'lucide-react'
5 | import { Input } from "@/components/ui/input";
6 | import { Button } from "@/components/ui/button";
7 |
8 | import { resetChatEngine } from "@/app/actions";
9 |
10 | export interface ChatMessage {
11 | role: "human" | "ai"
12 | statement: string
13 | }
14 |
15 | interface ChatWindowProps {
16 | isLoading: boolean,
17 | loadingMessage: string,
18 | startChat: (input: string) => void,
19 | messages: ChatMessage[],
20 | setMessages: Dispatch>,
21 | setSelectedFile: Dispatch>,
22 | setPage: Dispatch>
23 | }
24 |
25 | const ChatWindow: React.FC = ({
26 | isLoading,
27 | loadingMessage,
28 | messages,
29 | setMessages,
30 | startChat,
31 | setPage,
32 | setSelectedFile
33 | }) => {
34 | const [input, setInput] = useState("")
35 | const messageClass = "rounded-3xl p-3 block relative max-w-max"
36 | const aiMessageClass = `text-start rounded-bl bg-gray-300 float-left text-gray-700 ${messageClass}`
37 | const humanMessageClass = `text-end rounded-br bg-blue-400 text-gray-50 float-right ${messageClass}`
38 |
39 | const closePDF = async () => {
40 | await resetChatEngine();
41 | setMessages([]);
42 | setSelectedFile(null);
43 | setPage(1)
44 | }
45 |
46 | const resetChat = async () => {
47 | await resetChatEngine();
48 | setMessages([])
49 | setPage(1)
50 | }
51 |
52 | return (
53 |
54 |
55 |
56 | {isLoading && (
57 |
58 | )}
59 | {loadingMessage}
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | {/*
*/}
74 | {messages.map((message, index) => {
75 | return (
76 |
77 |
{message.statement}
78 |
79 | );
80 | })}
81 | {/*
*/}
82 |
83 |
{ setInput(e.target.value) }}
90 | />
91 |
92 | {
96 | setInput("")
97 | startChat(input)
98 | }}>Send
99 |
100 | {isLoading && (
101 |
102 | )}
103 |
104 |
105 |
106 |
107 | )
108 | }
109 |
110 | export default ChatWindow
111 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | In this tutorial we'll build a fully local chat-with-pdf app using LlamaIndexTS, Ollama, Next.JS.
2 |
3 |
4 | https://github.com/rsrohan99/local-pdf-ai/assets/62835870/6f2497ea-15b4-47ea-9482-dade56434b2b
5 |
6 |
7 | Stack used:
8 | - **LlamaIndex TS** as the RAG framework
9 | - **Ollama** to locally run LLM and embed models
10 | - **nomic-text-embed** with Ollama as the embed model
11 | - **phi2** with Ollama as the LLM
12 | - **Next.JS** with **server actions**
13 | - **PDFObject** to preview PDF with auto-scroll to relevant page
14 | - **LangChain** WebPDFLoader to parse the PDF
15 |
16 | ### Install Ollama
17 |
18 | We'll use Ollama to run the embed models and llms locally.
19 |
20 | Install Ollama
21 |
22 | ```bash
23 | $ curl -fsSL https://ollama.com/install.sh | sh
24 | ```
25 |
26 | ### Download nomic and phi model weights
27 |
28 | For this guide, I've used `phi2` as the LLM and `nomic-embed-text` as the embed model.
29 |
30 | To use the model, first we need to download their weights.
31 |
32 | ```bash
33 | $ ollama pull phi
34 |
35 | $ ollama pull nomic-embed-text
36 | ```
37 |
38 | But feel free to use any model you want.
39 |
40 | ### `FilePicker.tsx` - Drag-n-drop the PDF
41 |
42 | This component is the entry-point to our app.
43 |
44 | It's used for uploading the pdf file, either clicking the upload button or drag-and-drop the PDF file.
45 |
46 | ```ts
47 | return (
48 |
50 |
51 | Select PDF to chat
52 |
53 |
setStatus("Drop PDF file to chat")}
55 | onDragLeave={() => setStatus("")}
56 | onDrop={handleFileDrop}
57 | id="pdf"
58 | type="file"
59 | accept='.pdf'
60 | className="cursor-pointer"
61 | onChange={(e) => {
62 | if (e.target.files) {
63 | setSelectedFile(e.target.files[0])
64 | setPage(1)
65 | }
66 | }}
67 | />
68 |
{status}
69 |
70 | )
71 | ```
72 | After successfully upload, it sets the state variable `selectedFile` to the newly uploaded file.
73 |
74 |
75 | ### `Preview.tsx` - Preview of the PDF
76 |
77 | Once the state variable `selectedFile` is set, `ChatWindow` and `Preview` components are rendered instead of `FilePicker`
78 |
79 | First we get the base64 string of the pdf from the `File` using `FileReader`. Next we use this base64 string to preview the pdf.
80 |
81 | Preview component uses `PDFObject` package to render the PDF.
82 |
83 | It also takes `page` as prop to scroll to the relevant page. It's set to 1 initially and then updated as we chat with the PDF.
84 |
85 | ```ts
86 | useEffect(() => {
87 | const options = {
88 | title: fileToPreview.name,
89 | pdfOpenParams: {
90 | view: "fitH",
91 | page: page || 1,
92 | zoom: "scale,left,top",
93 | pageMode: 'none'
94 | }
95 | }
96 | console.log(`Page: ${page}`)
97 | const reader = new FileReader()
98 | reader.onload = () => {
99 | setb64String(reader.result as string);
100 | }
101 | reader.readAsDataURL(fileToPreview)
102 | pdfobject.embed(b64String as string, "#pdfobject", options)
103 | }, [page, b64String])
104 |
105 | return (
106 |
107 |
108 | )
109 | ```
110 |
111 |
112 | ### `ProcessPDF()` Next.JS server action
113 |
114 | We also have to process the PDF for RAG.
115 |
116 | We first use `LangChain` `WebPDFLoader` to parse the uploaded PDF. We use `WebPDFLoader` because it runs on the browser and don't require node.js.
117 |
118 | ```ts
119 | const loader = new WebPDFLoader(
120 | selectedFile,
121 | { parsedItemSeparator: " " }
122 | );
123 | const lcDocs = (await loader.load()).map(lcDoc => ({
124 | pageContent: lcDoc.pageContent,
125 | metadata: lcDoc.metadata,
126 | }))
127 | ```
128 |
129 | ### RAG using LlamaIndex TS
130 |
131 | Next, we pass the parsed documents to a Next.JS server action that initiates the RAG pipeline using `LlamaIndex TS`
132 | ```ts
133 | if (lcDocs.length == 0) return;
134 | const docs = lcDocs.map(lcDoc => new Document({
135 | text: lcDoc.pageContent,
136 | metadata: lcDoc.metadata
137 | }))
138 | ```
139 | we create LlamaIndex Documents from the parsed documents.
140 |
141 | #### Vector Store Index
142 |
143 | Next we create a `VectorStoreIndex` with those Documents, passing configuration info like which embed model and llm to use.
144 | ```ts
145 | const index = await VectorStoreIndex.fromDocuments(docs, {
146 | serviceContext: serviceContextFromDefaults({
147 | chunkSize: 300,
148 | chunkOverlap: 20,
149 | embedModel, llm
150 | })
151 | })
152 | ```
153 |
154 | We use Ollama for LLM and OllamaEmbedding for embed model
155 |
156 | ```ts
157 | const embedModel = new OllamaEmbedding({
158 | model: 'nomic-embed-text'
159 | })
160 |
161 | const llm = new Ollama({
162 | model: "phi",
163 | modelMetadata: {
164 | temperature: 0,
165 | maxTokens: 25,
166 | }
167 | })
168 | ```
169 |
170 | #### Vector Index Retriever
171 | We then create a `VectorIndexRetriever` from the `index`, which will be used to create a chat engine.
172 | ```ts
173 | const retriever = index.asRetriever({
174 | similarityTopK: 2,
175 | })
176 | if (chatEngine) {
177 | chatEngine.reset()
178 | }
179 | ```
180 |
181 | #### ChatEngine
182 | Finally, we create a LlamaIndex `ContextChatEngine` from the `Retriever`
183 | ```ts
184 | chatEngine = new ContextChatEngine({
185 | retriever,
186 | chatModel: llm
187 | })
188 | ```
189 | we pass in the LLM as well.
190 |
191 | ### `ChatWindow.tsx`
192 | This component is used to handle the Chat Logic
193 | ```ts
194 |
203 | ```
204 |
205 | ### `chat()` server action
206 | This server action used the previously created `ChatEngine` to generate chat response.
207 |
208 | In addition to the text response it also returns the source nodes used to generate the response, which we'll use later to updated which page to show on the PDF preview.
209 |
210 | ```ts
211 | const queryResult = await chatEngine.chat({
212 | message: query
213 | })
214 | const response = queryResult.response
215 | const metadata = queryResult.sourceNodes?.map(node => node.metadata)
216 | return { response, metadata };
217 | ```
218 |
219 | ### Update the page to preview from metadata
220 |
221 | We use the response and metadata from the above server action (`chat()`) to update the messages, and update the page to show in the PDF preview.
222 |
223 | ```ts
224 | setMessages(
225 | [
226 | ...messages,
227 | { role: 'human', statement: input },
228 | { role: 'ai', statement: response }
229 | ]
230 | )
231 | // console.log(metadata)
232 | if (metadata.length > 0) {
233 | setPage(metadata[0].loc.pageNumber)
234 | }
235 | setLoadingMessage("Got response from AI.")
236 | ```
237 |
238 | ### Few gotchas
239 |
240 | There're a few things to consider for this project:
241 | - You'll need a powerful machine with decent GPU to run Ollama for faster and better responses.
242 | - We need to disable `fs` on `browser` otherwise `pdf-parse` will not work. We need to put this in the `webpack` section of `next.config.js`
243 | ```ts
244 | if (!isServer) {
245 | config.resolve.fallback = {
246 | fs: false,
247 | "node:fs/promises": false,
248 | assert: false,
249 | module: false,
250 | perf_hooks: false,
251 | };
252 | }
253 | ```
254 | - Next.JS server actions don't support sending intermediate results, hence couldn't make streaming work.
255 |
256 |
257 | Thanks for reading. Stay tuned for more.
258 |
259 | I tweet about these topics and anything I'm exploring on a regular basis.
260 | [Follow me on twitter](https://twitter.com/clusteredbytes)
261 |
262 |
--------------------------------------------------------------------------------