├── .dockerignore ├── .eslintrc.cjs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── backend ├── .env.example ├── .gitignore ├── __tests__ │ ├── ingestion_graph │ │ └── state.test.ts │ └── retrieval_graph │ │ ├── integration.test.ts │ │ └── promptTemplate.test.ts ├── demo.ts ├── ingest-demo.ipynb ├── jest.config.js ├── langgraph.json ├── package.json ├── src │ ├── ingestion_graph │ │ ├── configuration.ts │ │ ├── graph.ts │ │ └── state.ts │ ├── retrieval_graph │ │ ├── configuration.ts │ │ ├── graph.ts │ │ ├── prompts.ts │ │ ├── state.ts │ │ └── utils.ts │ ├── sample_docs.json │ └── shared │ │ ├── configuration.ts │ │ ├── retrieval.ts │ │ ├── state.ts │ │ └── utils.ts ├── test_docs │ ├── docSplits.json │ └── test-tsla-10k-2023.pdf └── tsconfig.json ├── frontend ├── .env.example ├── .gitignore ├── __tests__ │ └── api │ │ └── ingest │ │ └── route.integration.test.ts ├── app │ ├── api │ │ ├── chat │ │ │ └── route.ts │ │ └── ingest │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components.json ├── components │ ├── chat-message.tsx │ ├── example-prompts.tsx │ ├── file-preview.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── use-mobile.tsx ├── constants │ └── graphConfigs.ts ├── hooks │ ├── use-mobile.tsx │ └── use-toast.ts ├── jest.config.js ├── jest.setup.js ├── lib │ ├── langgraph-base.ts │ ├── langgraph-client.ts │ ├── langgraph-server.ts │ ├── pdf.ts │ └── utils.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public │ ├── placeholder-logo.png │ ├── placeholder-logo.svg │ ├── placeholder-user.jpg │ ├── placeholder.jpg │ └── placeholder.svg ├── styles │ └── globals.css ├── tailwind.config.ts ├── tsconfig.json └── types │ └── graphTypes.ts ├── package.json ├── scripts └── checkLanggraphPaths.js ├── turbo.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* .eslintrc.cjs */ 2 | 3 | module.exports = { 4 | root: true, // Ensures ESLint doesn't look beyond this folder for configuration 5 | parser: '@typescript-eslint/parser', // Tells ESLint to parse TypeScript 6 | parserOptions: { 7 | ecmaVersion: 'latest', // Enables modern JavaScript features 8 | sourceType: 'module', // Allows import/export statements, 9 | project: './tsconfig.json', // Tells ESLint to use the tsconfig.json file 10 | }, 11 | extends: [ 12 | 'eslint:recommended', // Basic ESLint rules 13 | 'plugin:@typescript-eslint/recommended', // Adds TypeScript-specific rules 14 | 'prettier', // Disables rules conflicting with Prettier 15 | ], 16 | ignorePatterns: [ 17 | '.eslintrc.cjs', 18 | 'scripts', 19 | 'src/utils/lodash/*', 20 | 'node_modules', 21 | 'dist', 22 | 'dist-cjs', 23 | '*.js', 24 | '*.cjs', 25 | '*.d.ts', 26 | ], 27 | rules: { 28 | // You can add or override any rules here, for example: 29 | // "no-console": "warn", // Warn when using console.log 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Run formatting on all PRs 2 | 3 | name: CI 4 | 5 | on: 6 | push: 7 | branches: ["main"] 8 | pull_request: 9 | workflow_dispatch: # Allows triggering the workflow manually in GitHub UI 10 | 11 | # If another push to the same PR or branch happens while this workflow is still running, 12 | # cancel the earlier run in favor of the next run. 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | format: 19 | name: Check formatting 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js 18.x 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 18.x 27 | cache: "yarn" 28 | - name: Install dependencies 29 | run: yarn install --immutable --mode=skip-build 30 | - name: Check formatting 31 | run: yarn format:check 32 | 33 | lint: 34 | name: Check linting 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Use Node.js 18.x 39 | uses: actions/setup-node@v3 40 | with: 41 | node-version: 18.x 42 | cache: "yarn" 43 | - name: Install dependencies 44 | run: yarn install --immutable --mode=skip-build 45 | - name: Check linting 46 | run: yarn run lint:all 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | node_modules/ 3 | 4 | # misc 5 | .DS_Store 6 | *.pem 7 | 8 | # debug 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | ui-debug.log* 13 | 14 | # local env files 15 | .env*.local 16 | 17 | # typescript 18 | *.tsbuildinfo 19 | 20 | # vercel 21 | .vercel 22 | 23 | # testing 24 | coverage 25 | 26 | # next.js 27 | .next/ 28 | out/ 29 | 30 | # fumadocs 31 | .source 32 | 33 | # production 34 | build 35 | dist 36 | apps/docs/public/registry/ 37 | 38 | # turbo 39 | .turbo 40 | 41 | # env 42 | .env 43 | 44 | # cursor 45 | .cursor 46 | .cursor/ 47 | 48 | #llm_context 49 | llm_context/ 50 | 51 | test_docs 52 | 53 | .swc 54 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "tabWidth": 2 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 [Your Name or Organization Name] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= 2 | 3 | 4 | SUPABASE_URL= 5 | SUPABASE_SERVICE_ROLE_KEY= 6 | 7 | # Optional: LangSmith for tracing (recommended for development) 8 | LANGCHAIN_TRACING_V2=true 9 | # Optional: LangSmith API key to access deployed graph 10 | LANGCHAIN_API_KEY=your-langsmith-api-key-here 11 | LANGCHAIN_PROJECT=ai-agent-pdf-chatbot -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # LangGraph API 3 | .langgraph_api 4 | -------------------------------------------------------------------------------- /backend/__tests__/ingestion_graph/state.test.ts: -------------------------------------------------------------------------------- 1 | import { Document } from '@langchain/core/documents'; 2 | import { reduceDocs } from '../../src/shared/state.js'; 3 | 4 | describe('IndexStateAnnotation', () => { 5 | describe('docs reducer', () => { 6 | it('should handle adding new documents', () => { 7 | const initialDocs: Document[] = []; 8 | const newDoc = new Document({ 9 | pageContent: 'test content', 10 | metadata: { source: 'test.pdf', page: 1 }, 11 | }); 12 | 13 | const result = reduceDocs(initialDocs, [newDoc]); 14 | expect(result).toHaveLength(1); 15 | expect(result[0].pageContent).toBe('test content'); 16 | expect(result[0].metadata).toEqual({ source: 'test.pdf', page: 1 }); 17 | }); 18 | 19 | it('should handle merging multiple documents', () => { 20 | const initialDocs = [ 21 | new Document({ 22 | pageContent: 'initial doc', 23 | metadata: { source: 'initial.pdf', page: 1 }, 24 | }), 25 | ]; 26 | 27 | const newDocs = [ 28 | new Document({ 29 | pageContent: 'new doc 1', 30 | metadata: { source: 'new1.pdf', page: 1 }, 31 | }), 32 | new Document({ 33 | pageContent: 'new doc 2', 34 | metadata: { source: 'new2.pdf', page: 1 }, 35 | }), 36 | ]; 37 | 38 | const result = reduceDocs(initialDocs, newDocs); 39 | expect(result).toHaveLength(3); 40 | expect(result.map((doc: Document) => doc.pageContent)).toEqual([ 41 | 'initial doc', 42 | 'new doc 1', 43 | 'new doc 2', 44 | ]); 45 | expect(result.map((doc: Document) => doc.metadata.source)).toEqual([ 46 | 'initial.pdf', 47 | 'new1.pdf', 48 | 'new2.pdf', 49 | ]); 50 | }); 51 | 52 | it('should handle empty document arrays', () => { 53 | const initialDocs: Document[] = []; 54 | const newDocs: Document[] = []; 55 | 56 | const result = reduceDocs(initialDocs, newDocs); 57 | expect(result).toHaveLength(0); 58 | }); 59 | 60 | it('should handle "delete" action', () => { 61 | const initialDocs = [ 62 | new Document({ 63 | pageContent: 'to be deleted', 64 | metadata: { source: 'delete.pdf', page: 1 }, 65 | }), 66 | ]; 67 | 68 | const result = reduceDocs(initialDocs, 'delete'); 69 | expect(result).toHaveLength(0); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /backend/__tests__/retrieval_graph/promptTemplate.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ROUTER_SYSTEM_PROMPT, 3 | RESPONSE_SYSTEM_PROMPT, 4 | } from '../../src/retrieval_graph/prompts.js'; 5 | 6 | describe('Prompt Templates', () => { 7 | describe('ROUTER_SYSTEM_PROMPT', () => { 8 | it('should format the router prompt correctly', async () => { 9 | const query = 'What is the capital of France?'; 10 | const formattedPrompt = await ROUTER_SYSTEM_PROMPT.invoke({ 11 | query, 12 | }); 13 | 14 | expect(formattedPrompt.toString()).toContain( 15 | 'You are a routing assistant', 16 | ); 17 | expect(formattedPrompt.toString()).toContain(query); 18 | expect(formattedPrompt.toString()).toContain("'retrieve'"); 19 | expect(formattedPrompt.toString()).toContain("'direct'"); 20 | }); 21 | }); 22 | 23 | describe('RESPONSE_SYSTEM_PROMPT', () => { 24 | it('should format the response prompt correctly', async () => { 25 | const context = 'Paris is the capital of France.'; 26 | const question = 'Tell me about the capital of France.'; 27 | 28 | const formattedPrompt = await RESPONSE_SYSTEM_PROMPT.invoke({ 29 | context: 'Paris is the capital of France.', 30 | question: 'Tell me about the capital of France.', 31 | }); 32 | 33 | console.log(formattedPrompt.toString()); 34 | 35 | expect(formattedPrompt.toString()).toContain( 36 | 'You are an assistant for question-answering tasks', 37 | ); 38 | expect(formattedPrompt.toString()).toContain(context); 39 | expect(formattedPrompt.toString()).toContain(question); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /backend/demo.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@langchain/langgraph-sdk'; 2 | import { graph } from './src/retrieval_graph/graph.js'; 3 | import dotenv from 'dotenv'; 4 | 5 | // Load environment variables from .env file 6 | dotenv.config(); 7 | 8 | // Environment variables needed: 9 | // LANGGRAPH_API_URL: The URL where your LangGraph server is running 10 | // - For local development: http://localhost:2024 (or your local server port) 11 | // - For LangSmith cloud: https://api.smith.langchain.com 12 | // 13 | 14 | const assistant_id = 'retrieval_graph'; 15 | async function runDemo() { 16 | // Initialize the LangGraph client 17 | const client = new Client({ 18 | apiUrl: process.env.LANGGRAPH_API_URL || 'http://localhost:2024', 19 | }); 20 | 21 | // Create a new thread for this conversation 22 | console.log('Creating new thread...'); 23 | const thread = await client.threads.create({ 24 | metadata: { 25 | demo: 'retrieval-graph', 26 | }, 27 | }); 28 | console.log('Thread created with ID:', thread.thread_id); 29 | 30 | // Example question 31 | const question = 'What is this document about?'; 32 | 33 | console.log('\n=== Streaming Example ==='); 34 | console.log('Question:', question); 35 | 36 | // Run the graph with streaming 37 | try { 38 | console.log('\nStarting stream...'); 39 | const stream = await client.runs.stream(thread.thread_id, assistant_id, { 40 | input: { query: question }, 41 | streamMode: ['values', 'messages', 'updates'], // Include all stream types 42 | }); 43 | 44 | // Process the stream chunks 45 | console.log('\nWaiting for stream chunks...'); 46 | for await (const chunk of stream) { 47 | console.log('\nReceived chunk:'); 48 | // console.log('Event type:', chunk.event); 49 | if (chunk.event === 'values') { 50 | // console.log('Values data:', JSON.stringify(chunk.data, null, 2)); 51 | } else if (chunk.event === 'messages/partial') { 52 | // console.log('Messages data:', JSON.stringify(chunk, null, 2)); 53 | } else if (chunk.event === 'updates') { 54 | console.log('Update data:', JSON.stringify(chunk.data, null, 2)); 55 | } 56 | } 57 | console.log('\nStream completed.'); 58 | 59 | const messagesStream = await client.runs.stream( 60 | thread.thread_id, 61 | assistant_id, 62 | { 63 | input: { query: question }, 64 | streamMode: 'updates', // Include all stream types 65 | }, 66 | ); 67 | 68 | for await (const chunk of messagesStream) { 69 | console.log('\nReceived chunk:'); 70 | console.log('Event type:', chunk.event); 71 | console.log('updates data:', JSON.stringify(chunk.data, null, 2)); 72 | } 73 | } catch (error) { 74 | console.error('Error in streaming run:', error); 75 | // Log more details about the error 76 | if (error instanceof Error) { 77 | console.error('Error message:', error.message); 78 | console.error('Error stack:', error.stack); 79 | } 80 | } 81 | } 82 | 83 | // Run the demo 84 | runDemo().catch((error) => { 85 | console.error('Fatal error:', error); 86 | process.exit(1); 87 | }); 88 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | extensionsToTreatAsEsm: ['.ts'], 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | transform: { 10 | '^.+\\.tsx?$': [ 11 | 'ts-jest', 12 | { 13 | useESM: true, 14 | }, 15 | ], 16 | }, 17 | // Test configuration 18 | testMatch: ['**/__tests__/**/*.test.ts'], 19 | // Coverage configuration 20 | collectCoverageFrom: [ 21 | 'src/**/*.{ts,tsx}', 22 | '!src/**/*.d.ts', 23 | '!src/**/*.test.ts', 24 | ], 25 | coveragePathIgnorePatterns: ['/node_modules/', '/__tests__/', '/dist/'], 26 | coverageThreshold: { 27 | global: { 28 | branches: 80, 29 | functions: 80, 30 | lines: 80, 31 | statements: 80, 32 | }, 33 | }, 34 | coverageDirectory: 'coverage', 35 | // Helpful test output 36 | verbose: true, 37 | }; 38 | -------------------------------------------------------------------------------- /backend/langgraph.json: -------------------------------------------------------------------------------- 1 | { 2 | "node_version": "20", 3 | "graphs": { 4 | "ingestion_graph": "./src/ingestion_graph/graph.ts:graph", 5 | "retrieval_graph": "./src/retrieval_graph/graph.ts:graph" 6 | }, 7 | "env": ".env", 8 | "dependencies":["."] 9 | } -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "Chat with your PDF using this AI agent", 5 | "author": "Mayo Oshin", 6 | "license": "MIT", 7 | "private": true, 8 | "type": "module", 9 | "scripts": { 10 | "build": "tsc", 11 | "clean": "rm -rf dist", 12 | "demo": "npx tsx demo.ts", 13 | "test": "jest", 14 | "test:watch": "jest --watch", 15 | "test:changed": "jest --onlyChanged --passWithNoTests", 16 | "test:related": "jest --findRelatedTests", 17 | "test:coverage": "jest --coverage", 18 | "test:int": "jest --testPathPattern=\\.int\\.test\\.ts$", 19 | "format": "prettier --write .", 20 | "format:check": "prettier --check .", 21 | "lint": "eslint src", 22 | "lint:fix": "eslint src --fix", 23 | "lint:langgraph-json": "node scripts/checkLanggraphPaths.js", 24 | "lint:all": "yarn lint & yarn lint:langgraph-json", 25 | "langgraph:dev": "npx @langchain/langgraph-cli dev" 26 | }, 27 | "dependencies": { 28 | "@langchain/community": "^0.3.26", 29 | "@langchain/core": "^0.3.32", 30 | "@langchain/langgraph": "^0.2.41", 31 | "@langchain/langgraph-cli": "^0.0.1", 32 | "@langchain/langgraph-sdk": "^0.0.36", 33 | "@langchain/openai": "^0.3.17", 34 | "@mendable/firecrawl-js": "^1.15.7", 35 | "@supabase/supabase-js": "^2.48.1", 36 | "chromadb": "^1.10.4", 37 | "pdf-parse": "^1.1.1", 38 | "uuid": "^11.0.5", 39 | "ws": "^8.18.0", 40 | "zod": "^3.24.1" 41 | }, 42 | "devDependencies": { 43 | "@eslint/eslintrc": "^3.1.0", 44 | "@eslint/js": "^9.9.1", 45 | "@jest/globals": "^29.7.0", 46 | "@tsconfig/recommended": "^1.0.7", 47 | "@types/jest": "^29.5.0", 48 | "@types/node": "^22.10.6", 49 | "@typescript-eslint/eslint-plugin": "5.59.8", 50 | "@typescript-eslint/parser": "5.59.8", 51 | "cross-env": "^7.0.3", 52 | "dotenv": "^16.4.7", 53 | "eslint": "^8.41.0", 54 | "eslint-config-prettier": "^8.8.0", 55 | "eslint-plugin-import": "^2.27.5", 56 | "eslint-plugin-no-instanceof": "^1.0.1", 57 | "eslint-plugin-prettier": "^4.2.1", 58 | "jest": "^29.7.0", 59 | "prettier": "^3.3.3", 60 | "ts-jest": "^29.2.5", 61 | "tsx": "^4.19.2", 62 | "typescript": "^5.3.3" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /backend/src/ingestion_graph/configuration.ts: -------------------------------------------------------------------------------- 1 | import { Annotation } from '@langchain/langgraph'; 2 | import { RunnableConfig } from '@langchain/core/runnables'; 3 | import { 4 | BaseConfigurationAnnotation, 5 | ensureBaseConfiguration, 6 | } from '../shared/configuration.js'; 7 | 8 | // This file contains sample documents to index, based on the following LangChain and LangGraph documentation pages: 9 | const DEFAULT_DOCS_FILE = './src/sample_docs.json'; 10 | 11 | /** 12 | * The configuration for the indexing process. 13 | */ 14 | export const IndexConfigurationAnnotation = Annotation.Root({ 15 | ...BaseConfigurationAnnotation.spec, 16 | 17 | /** 18 | * Path to a JSON file containing default documents to index. 19 | */ 20 | docsFile: Annotation, 21 | useSampleDocs: Annotation, 22 | }); 23 | 24 | /** 25 | * Create an typeof IndexConfigurationAnnotation.State instance from a RunnableConfig object. 26 | * 27 | * @param config - The configuration object to use. 28 | * @returns An instance of typeof IndexConfigurationAnnotation.State with the specified configuration. 29 | */ 30 | export function ensureIndexConfiguration( 31 | config: RunnableConfig, 32 | ): typeof IndexConfigurationAnnotation.State { 33 | const configurable = (config?.configurable || {}) as Partial< 34 | typeof IndexConfigurationAnnotation.State 35 | >; 36 | 37 | const baseConfig = ensureBaseConfiguration(config); 38 | 39 | return { 40 | ...baseConfig, 41 | docsFile: configurable.docsFile || DEFAULT_DOCS_FILE, 42 | useSampleDocs: configurable.useSampleDocs || false, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/ingestion_graph/graph.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This "graph" simply exposes an endpoint for a user to upload docs to be indexed. 3 | */ 4 | 5 | import { RunnableConfig } from '@langchain/core/runnables'; 6 | import { StateGraph, END, START } from '@langchain/langgraph'; 7 | import fs from 'fs/promises'; 8 | 9 | import { IndexStateAnnotation } from './state.js'; 10 | import { makeRetriever } from '../shared/retrieval.js'; 11 | import { 12 | ensureIndexConfiguration, 13 | IndexConfigurationAnnotation, 14 | } from './configuration.js'; 15 | import { reduceDocs } from '../shared/state.js'; 16 | 17 | async function ingestDocs( 18 | state: typeof IndexStateAnnotation.State, 19 | config?: RunnableConfig, 20 | ): Promise { 21 | if (!config) { 22 | throw new Error('Configuration required to run index_docs.'); 23 | } 24 | 25 | const configuration = ensureIndexConfiguration(config); 26 | let docs = state.docs; 27 | 28 | if (!docs || docs.length === 0) { 29 | if (configuration.useSampleDocs) { 30 | const fileContent = await fs.readFile(configuration.docsFile, 'utf-8'); 31 | const serializedDocs = JSON.parse(fileContent); 32 | docs = reduceDocs([], serializedDocs); 33 | } else { 34 | throw new Error('No sample documents to index.'); 35 | } 36 | } else { 37 | docs = reduceDocs([], docs); 38 | } 39 | 40 | const retriever = await makeRetriever(config); 41 | await retriever.addDocuments(docs); 42 | 43 | return { docs: 'delete' }; 44 | } 45 | 46 | // Define the graph 47 | const builder = new StateGraph( 48 | IndexStateAnnotation, 49 | IndexConfigurationAnnotation, 50 | ) 51 | .addNode('ingestDocs', ingestDocs) 52 | .addEdge(START, 'ingestDocs') 53 | .addEdge('ingestDocs', END); 54 | 55 | // Compile into a graph object that you can invoke and deploy. 56 | export const graph = builder 57 | .compile() 58 | .withConfig({ runName: 'IngestionGraph' }); 59 | -------------------------------------------------------------------------------- /backend/src/ingestion_graph/state.ts: -------------------------------------------------------------------------------- 1 | import { Annotation } from '@langchain/langgraph'; 2 | import { Document } from '@langchain/core/documents'; 3 | import { reduceDocs } from '../shared/state.js'; 4 | 5 | /** 6 | * Represents the state for document indexing and retrieval. 7 | * 8 | * This interface defines the structure of the index state, which includes 9 | * the documents to be indexed and the retriever used for searching 10 | * these documents. 11 | */ 12 | export const IndexStateAnnotation = Annotation.Root({ 13 | /** 14 | * A list of documents that the agent can index. 15 | */ 16 | docs: Annotation< 17 | Document[], 18 | Document[] | { [key: string]: any }[] | string[] | string | 'delete' 19 | >({ 20 | default: () => [], 21 | reducer: reduceDocs, 22 | }), 23 | }); 24 | 25 | export type IndexStateType = typeof IndexStateAnnotation.State; 26 | -------------------------------------------------------------------------------- /backend/src/retrieval_graph/configuration.ts: -------------------------------------------------------------------------------- 1 | import { Annotation } from '@langchain/langgraph'; 2 | import { RunnableConfig } from '@langchain/core/runnables'; 3 | import { 4 | BaseConfigurationAnnotation, 5 | ensureBaseConfiguration, 6 | } from '../shared/configuration.js'; 7 | 8 | /** 9 | * The configuration for the agent. 10 | */ 11 | export const AgentConfigurationAnnotation = Annotation.Root({ 12 | ...BaseConfigurationAnnotation.spec, 13 | 14 | // models 15 | /** 16 | * The language model used for processing and refining queries. 17 | * Should be in the form: provider/model-name. 18 | */ 19 | queryModel: Annotation, 20 | }); 21 | 22 | /** 23 | * Create a typeof ConfigurationAnnotation.State instance from a RunnableConfig object. 24 | * 25 | * @param config - The configuration object to use. 26 | * @returns An instance of typeof ConfigurationAnnotation.State with the specified configuration. 27 | */ 28 | export function ensureAgentConfiguration( 29 | config: RunnableConfig, 30 | ): typeof AgentConfigurationAnnotation.State { 31 | const configurable = (config?.configurable || {}) as Partial< 32 | typeof AgentConfigurationAnnotation.State 33 | >; 34 | const baseConfig = ensureBaseConfiguration(config); 35 | return { 36 | ...baseConfig, 37 | queryModel: configurable.queryModel || 'openai/gpt-4o', 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/retrieval_graph/graph.ts: -------------------------------------------------------------------------------- 1 | import { StateGraph, START, END } from '@langchain/langgraph'; 2 | import { AgentStateAnnotation } from './state.js'; 3 | import { makeRetriever } from '../shared/retrieval.js'; 4 | import { formatDocs } from './utils.js'; 5 | import { HumanMessage } from '@langchain/core/messages'; 6 | import { z } from 'zod'; 7 | import { RESPONSE_SYSTEM_PROMPT, ROUTER_SYSTEM_PROMPT } from './prompts.js'; 8 | import { RunnableConfig } from '@langchain/core/runnables'; 9 | import { 10 | AgentConfigurationAnnotation, 11 | ensureAgentConfiguration, 12 | } from './configuration.js'; 13 | import { loadChatModel } from '../shared/utils.js'; 14 | 15 | async function checkQueryType( 16 | state: typeof AgentStateAnnotation.State, 17 | config: RunnableConfig, 18 | ): Promise<{ 19 | route: 'retrieve' | 'direct'; 20 | }> { 21 | //schema for routing 22 | const schema = z.object({ 23 | route: z.enum(['retrieve', 'direct']), 24 | directAnswer: z.string().optional(), 25 | }); 26 | 27 | const configuration = ensureAgentConfiguration(config); 28 | const model = await loadChatModel(configuration.queryModel); 29 | 30 | const routingPrompt = ROUTER_SYSTEM_PROMPT; 31 | 32 | const formattedPrompt = await routingPrompt.invoke({ 33 | query: state.query, 34 | }); 35 | 36 | const response = await model 37 | .withStructuredOutput(schema) 38 | .invoke(formattedPrompt.toString()); 39 | 40 | const route = response.route; 41 | 42 | return { route }; 43 | } 44 | 45 | async function answerQueryDirectly( 46 | state: typeof AgentStateAnnotation.State, 47 | config: RunnableConfig, 48 | ): Promise { 49 | const configuration = ensureAgentConfiguration(config); 50 | const model = await loadChatModel(configuration.queryModel); 51 | const userHumanMessage = new HumanMessage(state.query); 52 | 53 | const response = await model.invoke([userHumanMessage]); 54 | return { messages: [userHumanMessage, response] }; 55 | } 56 | 57 | async function routeQuery( 58 | state: typeof AgentStateAnnotation.State, 59 | ): Promise<'retrieveDocuments' | 'directAnswer'> { 60 | const route = state.route; 61 | if (!route) { 62 | throw new Error('Route is not set'); 63 | } 64 | 65 | if (route === 'retrieve') { 66 | return 'retrieveDocuments'; 67 | } else if (route === 'direct') { 68 | return 'directAnswer'; 69 | } else { 70 | throw new Error('Invalid route'); 71 | } 72 | } 73 | 74 | async function retrieveDocuments( 75 | state: typeof AgentStateAnnotation.State, 76 | config: RunnableConfig, 77 | ): Promise { 78 | const retriever = await makeRetriever(config); 79 | const response = await retriever.invoke(state.query); 80 | 81 | return { documents: response }; 82 | } 83 | 84 | async function generateResponse( 85 | state: typeof AgentStateAnnotation.State, 86 | config: RunnableConfig, 87 | ): Promise { 88 | const configuration = ensureAgentConfiguration(config); 89 | const context = formatDocs(state.documents); 90 | const model = await loadChatModel(configuration.queryModel); 91 | const promptTemplate = RESPONSE_SYSTEM_PROMPT; 92 | 93 | const formattedPrompt = await promptTemplate.invoke({ 94 | question: state.query, 95 | context: context, 96 | }); 97 | 98 | const userHumanMessage = new HumanMessage(state.query); 99 | 100 | // Create a human message with the formatted prompt that includes context 101 | const formattedPromptMessage = new HumanMessage(formattedPrompt.toString()); 102 | 103 | const messageHistory = [...state.messages, formattedPromptMessage]; 104 | 105 | // Let MessagesAnnotation handle the message history 106 | const response = await model.invoke(messageHistory); 107 | 108 | // Return both the current query and the AI response to be handled by MessagesAnnotation's reducer 109 | return { messages: [userHumanMessage, response] }; 110 | } 111 | 112 | const builder = new StateGraph( 113 | AgentStateAnnotation, 114 | AgentConfigurationAnnotation, 115 | ) 116 | .addNode('retrieveDocuments', retrieveDocuments) 117 | .addNode('generateResponse', generateResponse) 118 | .addNode('checkQueryType', checkQueryType) 119 | .addNode('directAnswer', answerQueryDirectly) 120 | .addEdge(START, 'checkQueryType') 121 | .addConditionalEdges('checkQueryType', routeQuery, [ 122 | 'retrieveDocuments', 123 | 'directAnswer', 124 | ]) 125 | .addEdge('retrieveDocuments', 'generateResponse') 126 | .addEdge('generateResponse', END) 127 | .addEdge('directAnswer', END); 128 | 129 | export const graph = builder.compile().withConfig({ 130 | runName: 'RetrievalGraph', 131 | }); 132 | -------------------------------------------------------------------------------- /backend/src/retrieval_graph/prompts.ts: -------------------------------------------------------------------------------- 1 | import { ChatPromptTemplate } from '@langchain/core/prompts'; 2 | 3 | const ROUTER_SYSTEM_PROMPT = ChatPromptTemplate.fromMessages([ 4 | [ 5 | 'system', 6 | "You are a routing assistant. Your job is to determine if a question needs document retrieval or can be answered directly.\n\nRespond with either:\n'retrieve' - if the question requires retrieving documents\n'direct' - if the question can be answered directly AND your direct answer", 7 | ], 8 | ['human', '{query}'], 9 | ]); 10 | 11 | const RESPONSE_SYSTEM_PROMPT = ChatPromptTemplate.fromMessages([ 12 | [ 13 | 'system', 14 | `You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. 15 | If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise. 16 | 17 | question: 18 | {question} 19 | 20 | context: 21 | {context} 22 | `, 23 | ], 24 | ]); 25 | 26 | export { ROUTER_SYSTEM_PROMPT, RESPONSE_SYSTEM_PROMPT }; 27 | -------------------------------------------------------------------------------- /backend/src/retrieval_graph/state.ts: -------------------------------------------------------------------------------- 1 | import { Annotation, MessagesAnnotation } from '@langchain/langgraph'; 2 | import { reduceDocs } from '../shared/state.js'; 3 | import { Document } from '@langchain/core/documents'; 4 | /** 5 | * Represents the state of the retrieval graph / agent. 6 | */ 7 | export const AgentStateAnnotation = Annotation.Root({ 8 | query: Annotation(), 9 | route: Annotation(), 10 | ...MessagesAnnotation.spec, 11 | 12 | /** 13 | * Populated by the retriever. This is a list of documents that the agent can reference. 14 | * @type {Document[]} 15 | */ 16 | documents: Annotation< 17 | Document[], 18 | Document[] | { [key: string]: any }[] | string[] | string | 'delete' 19 | >({ 20 | default: () => [], 21 | // @ts-ignore 22 | reducer: reduceDocs, 23 | }), 24 | 25 | // Additional attributes can be added here as needed 26 | }); 27 | -------------------------------------------------------------------------------- /backend/src/retrieval_graph/utils.ts: -------------------------------------------------------------------------------- 1 | import { Document } from '@langchain/core/documents'; 2 | 3 | export function formatDoc(doc: Document): string { 4 | const metadata = doc.metadata || {}; 5 | const meta = Object.entries(metadata) 6 | .map(([k, v]) => ` ${k}=${v}`) 7 | .join(''); 8 | const metaStr = meta ? ` ${meta}` : ''; 9 | 10 | return `\n${doc.pageContent}\n`; 11 | } 12 | 13 | export function formatDocs(docs?: Document[]): string { 14 | /**Format a list of documents as XML. */ 15 | if (!docs || docs.length === 0) { 16 | return ''; 17 | } 18 | const formatted = docs.map(formatDoc).join('\n'); 19 | return `\n${formatted}\n`; 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/shared/configuration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Define the configurable parameters for the agent. 3 | */ 4 | 5 | import { Annotation } from '@langchain/langgraph'; 6 | import { RunnableConfig } from '@langchain/core/runnables'; 7 | 8 | /** 9 | * typeof ConfigurationAnnotation.State class for indexing and retrieval operations. 10 | * 11 | * This annotation defines the parameters needed for configuring the indexing and 12 | * retrieval processes, including user identification, embedding model selection, 13 | * retriever provider choice, and search parameters. 14 | */ 15 | export const BaseConfigurationAnnotation = Annotation.Root({ 16 | /** 17 | * The vector store provider to use for retrieval. 18 | * Options are 'supabase', but you can add more providers here and create their own retriever functions 19 | */ 20 | retrieverProvider: Annotation<'supabase'>, 21 | 22 | /** 23 | * Additional keyword arguments to pass to the search function of the retriever for filtering. 24 | */ 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | filterKwargs: Annotation>, 27 | 28 | /** 29 | * The number of documents to retrieve. 30 | */ 31 | k: Annotation, 32 | }); 33 | 34 | /** 35 | * Create an typeof BaseConfigurationAnnotation.State instance from a RunnableConfig object. 36 | * 37 | * @param config - The configuration object to use. 38 | * @returns An instance of typeof BaseConfigurationAnnotation.State with the specified configuration. 39 | */ 40 | export function ensureBaseConfiguration( 41 | config: RunnableConfig, 42 | ): typeof BaseConfigurationAnnotation.State { 43 | const configurable = (config?.configurable || {}) as Partial< 44 | typeof BaseConfigurationAnnotation.State 45 | >; 46 | return { 47 | retrieverProvider: configurable.retrieverProvider || 'supabase', 48 | filterKwargs: configurable.filterKwargs || {}, 49 | k: configurable.k || 5, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/shared/retrieval.ts: -------------------------------------------------------------------------------- 1 | import { VectorStoreRetriever } from '@langchain/core/vectorstores'; 2 | import { OpenAIEmbeddings } from '@langchain/openai'; 3 | import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'; 4 | import { createClient } from '@supabase/supabase-js'; 5 | import { RunnableConfig } from '@langchain/core/runnables'; 6 | import { 7 | BaseConfigurationAnnotation, 8 | ensureBaseConfiguration, 9 | } from './configuration.js'; 10 | 11 | export async function makeSupabaseRetriever( 12 | configuration: typeof BaseConfigurationAnnotation.State, 13 | ): Promise { 14 | if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) { 15 | throw new Error( 16 | 'SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables are not defined', 17 | ); 18 | } 19 | const embeddings = new OpenAIEmbeddings({ 20 | model: 'text-embedding-3-small', 21 | }); 22 | const supabaseClient = createClient( 23 | process.env.SUPABASE_URL ?? '', 24 | process.env.SUPABASE_SERVICE_ROLE_KEY ?? '', 25 | ); 26 | const vectorStore = new SupabaseVectorStore(embeddings, { 27 | client: supabaseClient, 28 | tableName: 'documents', 29 | queryName: 'match_documents', 30 | }); 31 | return vectorStore.asRetriever({ 32 | k: configuration.k, 33 | filter: configuration.filterKwargs, 34 | }); 35 | } 36 | 37 | export async function makeRetriever( 38 | config: RunnableConfig, 39 | ): Promise { 40 | const configuration = ensureBaseConfiguration(config); 41 | switch (configuration.retrieverProvider) { 42 | case 'supabase': 43 | return makeSupabaseRetriever(configuration); 44 | default: 45 | throw new Error( 46 | `Unsupported retriever provider: ${configuration.retrieverProvider}`, 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /backend/src/shared/state.ts: -------------------------------------------------------------------------------- 1 | import { Document } from '@langchain/core/documents'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | /** 5 | * Reduces the document array based on the provided new documents or actions. 6 | * 7 | * @param existing - The existing array of documents. 8 | * @param newDocs - The new documents or actions to apply. 9 | * @returns The updated array of documents. 10 | */ 11 | export function reduceDocs( 12 | existing?: Document[], 13 | newDocs?: 14 | | Document[] 15 | | { [key: string]: any }[] 16 | | string[] 17 | | string 18 | | 'delete', 19 | ): Document[] { 20 | if (newDocs === 'delete') { 21 | return []; 22 | } 23 | 24 | const existingList = existing || []; 25 | const existingIds = new Set(existingList.map((doc) => doc.metadata?.uuid)); 26 | 27 | if (typeof newDocs === 'string') { 28 | const docId = uuidv4(); 29 | return [ 30 | ...existingList, 31 | { pageContent: newDocs, metadata: { uuid: docId } }, 32 | ]; 33 | } 34 | 35 | const newList: Document[] = []; 36 | if (Array.isArray(newDocs)) { 37 | for (const item of newDocs) { 38 | if (typeof item === 'string') { 39 | const itemId = uuidv4(); 40 | newList.push({ pageContent: item, metadata: { uuid: itemId } }); 41 | existingIds.add(itemId); 42 | } else if (typeof item === 'object') { 43 | const metadata = (item as Document).metadata ?? {}; 44 | let itemId = metadata.uuid ?? uuidv4(); 45 | 46 | if (!existingIds.has(itemId)) { 47 | if ('pageContent' in item) { 48 | // It's a Document-like object 49 | newList.push({ 50 | ...(item as Document), 51 | metadata: { ...metadata, uuid: itemId }, 52 | }); 53 | } else { 54 | // It's a generic object, treat it as metadata 55 | newList.push({ 56 | pageContent: '', 57 | metadata: { ...(item as { [key: string]: any }), uuid: itemId }, 58 | }); 59 | } 60 | existingIds.add(itemId); 61 | } 62 | } 63 | } 64 | } 65 | 66 | return [...existingList, ...newList]; 67 | } 68 | -------------------------------------------------------------------------------- /backend/src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { BaseChatModel } from '@langchain/core/language_models/chat_models'; 2 | import { initChatModel } from 'langchain/chat_models/universal'; 3 | 4 | const SUPPORTED_PROVIDERS = [ 5 | 'openai', 6 | 'anthropic', 7 | 'azure_openai', 8 | 'cohere', 9 | 'google-vertexai', 10 | 'google-vertexai-web', 11 | 'google-genai', 12 | 'ollama', 13 | 'together', 14 | 'fireworks', 15 | 'mistralai', 16 | 'groq', 17 | 'bedrock', 18 | 'cerebras', 19 | 'deepseek', 20 | 'xai', 21 | ] as const; 22 | /** 23 | * Load a chat model from a fully specified name. 24 | * @param fullySpecifiedName - String in the format 'provider/model' or 'provider/account/provider/model'. 25 | * @returns A Promise that resolves to a BaseChatModel instance. 26 | */ 27 | export async function loadChatModel( 28 | fullySpecifiedName: string, 29 | temperature: number = 0.2, 30 | ): Promise { 31 | const index = fullySpecifiedName.indexOf('/'); 32 | if (index === -1) { 33 | // If there's no "/", assume it's just the model 34 | if ( 35 | !SUPPORTED_PROVIDERS.includes( 36 | fullySpecifiedName as (typeof SUPPORTED_PROVIDERS)[number], 37 | ) 38 | ) { 39 | throw new Error(`Unsupported model: ${fullySpecifiedName}`); 40 | } 41 | return await initChatModel(fullySpecifiedName, { 42 | temperature: temperature, 43 | }); 44 | } else { 45 | const provider = fullySpecifiedName.slice(0, index); 46 | const model = fullySpecifiedName.slice(index + 1); 47 | if ( 48 | !SUPPORTED_PROVIDERS.includes( 49 | provider as (typeof SUPPORTED_PROVIDERS)[number], 50 | ) 51 | ) { 52 | throw new Error(`Unsupported provider: ${provider}`); 53 | } 54 | return await initChatModel(model, { 55 | modelProvider: provider, 56 | temperature: temperature, 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/test_docs/test-tsla-10k-2023.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayooear/ai-pdf-chatbot-langchain/4bb98092472d0af57db600a10ba2183d76adecc4/backend/test_docs/test-tsla-10k-2023.pdf -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended", 3 | "compilerOptions": { 4 | "target": "ES2021", 5 | "lib": ["ES2021", "ES2022.Object", "DOM"], 6 | "module": "NodeNext", 7 | "moduleResolution": "nodenext", 8 | "esModuleInterop": true, 9 | "noImplicitReturns": true, 10 | "declaration": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "useDefineForClassFields": true, 15 | "strictPropertyInitialization": false, 16 | "allowJs": true, 17 | "strict": true, 18 | "strictFunctionTypes": false, 19 | "outDir": "dist", 20 | "types": ["jest", "node"], 21 | "resolveJsonModule": true 22 | }, 23 | "include": ["**/*.ts", "**/*.js", "jest.setup.cjs"], 24 | "exclude": ["node_modules", "dist"] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_LANGGRAPH_API_URL=http://localhost:2024 2 | LANGCHAIN_API_KEY= 3 | LANGGRAPH_INGESTION_ASSISTANT_ID=ingestion_graph 4 | LANGGRAPH_RETRIEVAL_ASSISTANT_ID=retrieval_graph 5 | 6 | LANGCHAIN_TRACING_V2=true 7 | 8 | LANGCHAIN_PROJECT="pdf-chatbot" -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # next.js 7 | /.next/ 8 | /out/ 9 | 10 | # production 11 | /build 12 | 13 | # debug 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | .pnpm-debug.log* 18 | 19 | # env files 20 | .env 21 | .env.local 22 | 23 | # vercel 24 | .vercel 25 | 26 | # typescript 27 | *.tsbuildinfo 28 | next-env.d.ts -------------------------------------------------------------------------------- /frontend/__tests__/api/ingest/route.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { POST } from '../../../app/api/ingest/route'; // Import the actual route handler 2 | import { NextRequest } from 'next/server'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import fetch from 'node-fetch'; 6 | import FormData from 'form-data'; 7 | import { processPDF } from '@/lib/pdf'; 8 | import { langGraphServerClient } from '@/lib/langgraph-server'; 9 | 10 | // Mock the processPDF function 11 | jest.mock('@/lib/pdf', () => ({ 12 | processPDF: jest.fn().mockImplementation((file: File) => { 13 | return Promise.resolve([ 14 | { 15 | pageContent: 'Test content', 16 | metadata: { filename: file.name }, 17 | }, 18 | ]); 19 | }), 20 | })); 21 | 22 | // Mock the langGraphServerClient 23 | jest.mock('@/lib/langgraph-server', () => { 24 | return { 25 | langGraphServerClient: { 26 | createThread: jest 27 | .fn() 28 | .mockResolvedValue({ thread_id: 'test-thread-id' }), 29 | client: { 30 | runs: { 31 | stream: jest.fn().mockImplementation(async function* () { 32 | yield { data: 'test' }; 33 | }), 34 | }, 35 | }, 36 | }, 37 | }; 38 | }); 39 | describe('PDF Ingest Route (node-fetch)', () => { 40 | const baseUrl = 'http://localhost:3000'; // Replace with your dev server URL 41 | const ingestUrl = `${baseUrl}/api/ingest`; 42 | const pdfFilePath = path.join(__dirname, 'test.pdf'); 43 | 44 | beforeAll(() => { 45 | // Create a dummy PDF file for testing 46 | const minimalPDF = `%PDF-1.7 47 | 1 0 obj<>endobj 48 | 2 0 obj<>endobj 49 | 3 0 obj<>/Contents 4 0 R>>endobj 50 | 4 0 obj<>stream 51 | BT /F1 12 Tf (Test) Tj ET 52 | endstream 53 | endobj 54 | xref 55 | 0 5 56 | 0000000000 65535 f 57 | 0000000009 00000 n 58 | 0000000056 00000 n 59 | 0000000107 00000 n 60 | 0000000200 00000 n 61 | trailer<> 62 | startxref 63 | 271 64 | %%EOF`; 65 | fs.writeFileSync(pdfFilePath, minimalPDF); 66 | }); 67 | 68 | afterAll(() => { 69 | // Clean up the dummy PDF file 70 | fs.unlinkSync(pdfFilePath); 71 | }); 72 | 73 | it.skip('should reject empty requests', async () => { 74 | const formData = new FormData(); 75 | const response = await fetch(ingestUrl, { 76 | method: 'POST', 77 | body: formData, 78 | }); 79 | 80 | expect(response.status).toBe(400); 81 | const data = await response.json(); 82 | expect(data.error).toBe('No files provided'); 83 | }); 84 | 85 | it('should reject non-PDF files', async () => { 86 | const formData = new FormData(); 87 | formData.append('files', fs.createReadStream('jest.config.js'), 'test.txt'); // Attach a non-PDF file 88 | 89 | const response = await fetch(ingestUrl, { 90 | method: 'POST', 91 | body: formData, 92 | }); 93 | 94 | expect(response.status).toBe(400); 95 | const data = await response.json(); 96 | expect(data.error).toContain('Only PDF files are allowed'); 97 | }); 98 | 99 | it('should accept PDF files', async () => { 100 | const formData = new FormData(); 101 | formData.append('files', fs.createReadStream(pdfFilePath), 'test.pdf'); // Attach the PDF file 102 | 103 | const response = await fetch(ingestUrl, { 104 | method: 'POST', 105 | body: formData, 106 | }); 107 | 108 | expect(response.status).toBe(200); 109 | const data = await response.json(); 110 | expect(data.message).toContain(''); 111 | expect(data.threadId).toMatch( 112 | /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, 113 | ); 114 | }); 115 | 116 | it('should handle multiple PDFs', async () => { 117 | const formData = new FormData(); 118 | formData.append('files', fs.createReadStream(pdfFilePath), 'test1.pdf'); 119 | formData.append('files', fs.createReadStream(pdfFilePath), 'test2.pdf'); 120 | 121 | const response = await fetch(ingestUrl, { 122 | method: 'POST', 123 | body: formData, 124 | }); 125 | 126 | expect(response.status).toBe(200); 127 | const data = await response.json(); 128 | expect(data.message).toBe('Documents ingested successfully'); 129 | expect(data.threadId).toMatch( 130 | /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, 131 | ); 132 | }); 133 | 134 | it.skip('should correctly parse PDF files using PDFLoader', async () => { 135 | const formData = new FormData(); 136 | formData.append('files', fs.createReadStream(pdfFilePath), 'test.pdf'); 137 | 138 | await fetch(ingestUrl, { 139 | method: 'POST', 140 | body: formData, 141 | }); 142 | 143 | expect(processPDF).toHaveBeenCalled(); 144 | }); 145 | 146 | it.skip('should call the ingestion graph with the correct data', async () => { 147 | const formData = new FormData(); 148 | formData.append('files', fs.createReadStream(pdfFilePath), 'test.pdf'); 149 | 150 | await fetch(ingestUrl, { 151 | method: 'POST', 152 | body: formData, 153 | }); 154 | 155 | expect(langGraphServerClient.createThread).toHaveBeenCalled(); 156 | expect(langGraphServerClient.client.runs.stream).toHaveBeenCalledWith( 157 | 'test-thread-id', 158 | 'ingestion_graph', 159 | { 160 | input: { 161 | docs: [ 162 | { pageContent: 'Test content', metadata: { filename: 'test.pdf' } }, 163 | ], 164 | }, 165 | }, 166 | ); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /frontend/app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { createServerClient } from '@/lib/langgraph-server'; 3 | import { retrievalAssistantStreamConfig } from '@/constants/graphConfigs'; 4 | 5 | export const runtime = 'edge'; 6 | 7 | export async function POST(req: Request) { 8 | try { 9 | const { message, threadId } = await req.json(); 10 | 11 | if (!message) { 12 | return new NextResponse( 13 | JSON.stringify({ error: 'Message is required' }), 14 | { 15 | status: 400, 16 | headers: { 'Content-Type': 'application/json' }, 17 | }, 18 | ); 19 | } 20 | 21 | if (!threadId) { 22 | return new NextResponse( 23 | JSON.stringify({ error: 'Thread ID is required' }), 24 | { 25 | status: 400, 26 | headers: { 'Content-Type': 'application/json' }, 27 | }, 28 | ); 29 | } 30 | 31 | if (!process.env.LANGGRAPH_RETRIEVAL_ASSISTANT_ID) { 32 | return new NextResponse( 33 | JSON.stringify({ 34 | error: 'LANGGRAPH_RETRIEVAL_ASSISTANT_ID is not set', 35 | }), 36 | { status: 500, headers: { 'Content-Type': 'application/json' } }, 37 | ); 38 | } 39 | 40 | try { 41 | const assistantId = process.env.LANGGRAPH_RETRIEVAL_ASSISTANT_ID; 42 | const serverClient = createServerClient(); 43 | 44 | const stream = await serverClient.client.runs.stream( 45 | threadId, 46 | assistantId, 47 | { 48 | input: { query: message }, 49 | streamMode: ['messages', 'updates'], 50 | config: { 51 | configurable: { 52 | ...retrievalAssistantStreamConfig, 53 | }, 54 | }, 55 | }, 56 | ); 57 | 58 | // Set up response as a stream 59 | const encoder = new TextEncoder(); 60 | const customReadable = new ReadableStream({ 61 | async start(controller) { 62 | try { 63 | // Forward each chunk from the graph to the client 64 | for await (const chunk of stream) { 65 | // Only send relevant chunks 66 | controller.enqueue( 67 | encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`), 68 | ); 69 | } 70 | } catch (error) { 71 | console.error('Streaming error:', error); 72 | controller.enqueue( 73 | encoder.encode( 74 | `data: ${JSON.stringify({ error: 'Streaming error occurred' })}\n\n`, 75 | ), 76 | ); 77 | } finally { 78 | controller.close(); 79 | } 80 | }, 81 | }); 82 | 83 | // Return the stream with appropriate headers 84 | return new Response(customReadable, { 85 | headers: { 86 | 'Content-Type': 'text/event-stream', 87 | 'Cache-Control': 'no-cache', 88 | Connection: 'keep-alive', 89 | }, 90 | }); 91 | } catch (error) { 92 | // Handle streamRun errors 93 | console.error('Stream initialization error:', error); 94 | return new NextResponse( 95 | JSON.stringify({ error: 'Internal server error' }), 96 | { 97 | status: 500, 98 | headers: { 'Content-Type': 'application/json' }, 99 | }, 100 | ); 101 | } 102 | } catch (error) { 103 | // Handle JSON parsing errors 104 | console.error('Route error:', error); 105 | return new NextResponse( 106 | JSON.stringify({ error: 'Internal server error' }), 107 | { 108 | status: 500, 109 | headers: { 'Content-Type': 'application/json' }, 110 | }, 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /frontend/app/api/ingest/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/ingest/route.ts 2 | import { indexConfig } from '@/constants/graphConfigs'; 3 | import { langGraphServerClient } from '@/lib/langgraph-server'; 4 | import { processPDF } from '@/lib/pdf'; 5 | import { Document } from '@langchain/core/documents'; 6 | import { NextRequest, NextResponse } from 'next/server'; 7 | 8 | // Configuration constants 9 | const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB 10 | const ALLOWED_FILE_TYPES = ['application/pdf']; 11 | 12 | export async function POST(request: NextRequest) { 13 | try { 14 | if (!process.env.LANGGRAPH_INGESTION_ASSISTANT_ID) { 15 | return NextResponse.json( 16 | { 17 | error: 18 | 'LANGGRAPH_INGESTION_ASSISTANT_ID is not set in your environment variables', 19 | }, 20 | { status: 500 }, 21 | ); 22 | } 23 | 24 | const formData = await request.formData(); 25 | const files: File[] = []; 26 | 27 | for (const [key, value] of formData.entries()) { 28 | if (key === 'files' && value instanceof File) { 29 | files.push(value); 30 | } 31 | } 32 | 33 | if (!files || files.length === 0) { 34 | return NextResponse.json({ error: 'No files provided' }, { status: 400 }); 35 | } 36 | 37 | // Validate file count 38 | if (files.length > 5) { 39 | return NextResponse.json( 40 | { error: 'Too many files. Maximum 5 files allowed.' }, 41 | { status: 400 }, 42 | ); 43 | } 44 | 45 | // Validate file types and sizes 46 | const invalidFiles = files.filter((file) => { 47 | return ( 48 | !ALLOWED_FILE_TYPES.includes(file.type) || file.size > MAX_FILE_SIZE 49 | ); 50 | }); 51 | 52 | if (invalidFiles.length > 0) { 53 | return NextResponse.json( 54 | { 55 | error: 56 | 'Only PDF files are allowed and file size must be less than 10MB', 57 | }, 58 | { status: 400 }, 59 | ); 60 | } 61 | 62 | // Process all PDFs into Documents 63 | const allDocs: Document[] = []; 64 | for (const file of files) { 65 | try { 66 | const docs = await processPDF(file); 67 | allDocs.push(...docs); 68 | } catch (error: any) { 69 | console.error(`Error processing file ${file.name}:`, error); 70 | // Continue processing other files; errors are logged 71 | } 72 | } 73 | 74 | if (!allDocs.length) { 75 | return NextResponse.json( 76 | { error: 'No valid documents extracted from uploaded files' }, 77 | { status: 500 }, 78 | ); 79 | } 80 | 81 | // Run the ingestion graph 82 | const thread = await langGraphServerClient.createThread(); 83 | const ingestionRun = await langGraphServerClient.client.runs.wait( 84 | thread.thread_id, 85 | 'ingestion_graph', 86 | { 87 | input: { 88 | docs: allDocs, 89 | }, 90 | config: { 91 | configurable: { 92 | ...indexConfig, 93 | }, 94 | }, 95 | }, 96 | ); 97 | 98 | return NextResponse.json({ 99 | message: 'Documents ingested successfully', 100 | threadId: thread.thread_id, 101 | }); 102 | } catch (error: any) { 103 | console.error('Error processing files:', error); 104 | return NextResponse.json( 105 | { error: 'Failed to process files', details: error.message }, 106 | { status: 500 }, 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /frontend/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: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 0 0% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 0 0% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 0 0% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 0 0% 9%; 50 | 51 | --secondary: 0 0% 14.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 0 0% 14.9%; 55 | --muted-foreground: 0 0% 63.9%; 56 | 57 | --accent: 0 0% 14.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 0 0% 14.9%; 64 | --input: 0 0% 14.9%; 65 | --ring: 0 0% 83.1%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import type { Metadata } from "next" 3 | import { GeistSans } from "geist/font/sans" 4 | import { Toaster } from "@/components/ui/toaster" 5 | 6 | import "./globals.css" 7 | 8 | export const metadata: Metadata = { 9 | title: "Learning LangChain Book Chatbot Demo", 10 | description: "A chatbot demo based on Learning LangChain (O'Reilly)", 11 | } 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode 17 | }) { 18 | return ( 19 | 20 | 21 | {children} 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | 29 | 30 | import './globals.css' -------------------------------------------------------------------------------- /frontend/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": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /frontend/components/chat-message.tsx: -------------------------------------------------------------------------------- 1 | import { Copy } from 'lucide-react'; 2 | import { Button } from '@/components/ui/button'; 3 | import { Card, CardContent } from '@/components/ui/card'; 4 | import { useState } from 'react'; 5 | import { PDFDocument } from '@/types/graphTypes'; 6 | import { 7 | Accordion, 8 | AccordionContent, 9 | AccordionItem, 10 | AccordionTrigger, 11 | } from '@/components/ui/accordion'; 12 | 13 | interface ChatMessageProps { 14 | message: { 15 | role: 'user' | 'assistant'; 16 | content: string; 17 | sources?: PDFDocument[]; 18 | }; 19 | } 20 | 21 | export function ChatMessage({ message }: ChatMessageProps) { 22 | const isUser = message.role === 'user'; 23 | const [copied, setCopied] = useState(false); 24 | const isLoading = message.role === 'assistant' && message.content === ''; 25 | 26 | const handleCopy = async () => { 27 | try { 28 | await navigator.clipboard.writeText(message.content); 29 | setCopied(true); 30 | setTimeout(() => setCopied(false), 2000); 31 | } catch (err) { 32 | console.error('Failed to copy text:', err); 33 | } 34 | }; 35 | 36 | const showSources = 37 | message.role === 'assistant' && 38 | message.sources && 39 | message.sources.length > 0; 40 | 41 | return ( 42 |
43 |
46 | {isLoading ? ( 47 |
48 |
49 |
50 |
51 |
52 | ) : ( 53 | <> 54 |

{message.content}

55 | {!isUser && ( 56 |
57 | 68 |
69 | )} 70 | {showSources && message.sources && ( 71 | 72 | 73 | 74 | View Sources ({message.sources.length}) 75 | 76 | 77 |
78 | {message.sources?.map((source, index) => ( 79 | 83 | 84 |

85 | {source.metadata?.source || 86 | source.metadata?.filename || 87 | 'N/A'} 88 |

89 |

90 | Page {source.metadata?.loc?.pageNumber || 'N/A'} 91 |

92 |
93 |
94 | ))} 95 |
96 |
97 |
98 |
99 | )} 100 | 101 | )} 102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /frontend/components/example-prompts.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@/components/ui/card" 2 | 3 | interface ExamplePromptsProps { 4 | onPromptSelect: (prompt: string) => void 5 | } 6 | 7 | const EXAMPLE_PROMPTS = [ 8 | { 9 | title: "What is this document about?", 10 | }, 11 | { 12 | title: "What is music?", 13 | }, 14 | ] 15 | 16 | export function ExamplePrompts({ onPromptSelect }: ExamplePromptsProps) { 17 | return ( 18 |
19 | {EXAMPLE_PROMPTS.map((prompt, i) => ( 20 | onPromptSelect(prompt.title)} 24 | > 25 |

{prompt.title}

26 |
27 | ))} 28 |
29 | ) 30 | } 31 | 32 | -------------------------------------------------------------------------------- /frontend/components/file-preview.tsx: -------------------------------------------------------------------------------- 1 | import { FileIcon, X } from "lucide-react" 2 | import { Button } from "@/components/ui/button" 3 | 4 | interface FilePreviewProps { 5 | file: File 6 | onRemove: () => void 7 | } 8 | 9 | export function FilePreview({ file, onRemove }: FilePreviewProps) { 10 | return ( 11 |
12 |
13 | 14 |
15 |
16 |

{file.name}

17 |

PDF

18 |
19 | 27 |
28 | ) 29 | } 30 | 31 | -------------------------------------------------------------------------------- /frontend/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { 5 | ThemeProvider as NextThemesProvider, 6 | type ThemeProviderProps, 7 | } from 'next-themes' 8 | 9 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /frontend/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 59 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /frontend/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /frontend/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /frontend/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /frontend/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /frontend/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>