├── .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 | 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 | 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 |
81 | 90 | 91 |
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 | 65 | 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 | 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 | 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 | --------------------------------------------------------------------------------