├── config └── tools.json ├── app ├── api │ ├── chat │ │ ├── engine │ │ │ ├── shared.ts │ │ │ ├── index.ts │ │ │ ├── loader.ts │ │ │ ├── chat.ts │ │ │ ├── queryFilter.ts │ │ │ ├── generate.ts │ │ │ └── settings.ts │ │ ├── config │ │ │ ├── route.ts │ │ │ └── llamacloud │ │ │ │ └── route.ts │ │ ├── llamaindex │ │ │ ├── documents │ │ │ │ ├── pipeline.ts │ │ │ │ ├── upload.ts │ │ │ │ └── helper.ts │ │ │ └── streaming │ │ │ │ ├── file.ts │ │ │ │ ├── stream.ts │ │ │ │ ├── suggestion.ts │ │ │ │ ├── annotations.ts │ │ │ │ └── events.ts │ │ ├── upload │ │ │ └── route.ts │ │ └── route.ts │ └── files │ │ └── [...slug] │ │ └── route.ts ├── favicon.ico ├── components │ ├── ui │ │ ├── README.md │ │ ├── collapsible.tsx │ │ ├── lib │ │ │ └── utils.ts │ │ ├── chat │ │ │ ├── chat-message │ │ │ │ ├── chat-files.tsx │ │ │ │ ├── chat-image.tsx │ │ │ │ ├── chat-avatar.tsx │ │ │ │ ├── chat-tools.tsx │ │ │ │ ├── chat-suggestedQuestions.tsx │ │ │ │ ├── chat-events.tsx │ │ │ │ ├── codeblock.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── markdown.tsx │ │ │ │ └── chat-sources.tsx │ │ │ ├── hooks │ │ │ │ ├── use-config.ts │ │ │ │ ├── use-copy-to-clipboard.tsx │ │ │ │ └── use-file.ts │ │ │ ├── chat.interface.ts │ │ │ ├── chat-actions.tsx │ │ │ ├── widgets │ │ │ │ ├── PdfDialog.tsx │ │ │ │ ├── LlamaCloudSelector.tsx │ │ │ │ └── WeatherCard.tsx │ │ │ ├── index.ts │ │ │ ├── chat-messages.tsx │ │ │ └── chat-input.tsx │ │ ├── upload-image-preview.tsx │ │ ├── input.tsx │ │ ├── icons │ │ │ ├── docx.svg │ │ │ ├── txt.svg │ │ │ ├── pdf.svg │ │ │ └── sheet.svg │ │ ├── hover-card.tsx │ │ ├── button.tsx │ │ ├── file-uploader.tsx │ │ ├── drawer.tsx │ │ ├── document-preview.tsx │ │ └── select.tsx │ ├── header.tsx │ └── chat-section.tsx ├── observability │ └── index.ts ├── markdown.css ├── page.tsx ├── layout.tsx └── globals.css ├── prettier.config.js ├── public └── llama.png ├── postcss.config.js ├── .eslintrc.json ├── next.config.json ├── Dockerfile ├── webpack.config.mjs ├── next.config.mjs ├── README.md ├── .gitignore ├── tsconfig.json ├── shell └── formatCsvData.js ├── .env ├── .devcontainer └── devcontainer.json ├── package.json ├── .env.template └── tailwind.config.ts /config/tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "local": {}, 3 | "llamahub": {} 4 | } -------------------------------------------------------------------------------- /app/api/chat/engine/shared.ts: -------------------------------------------------------------------------------- 1 | export const STORAGE_CACHE_DIR = "./cache"; 2 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IamLiuLv/business-component-codegen/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["prettier-plugin-organize-imports"], 3 | }; 4 | -------------------------------------------------------------------------------- /public/llama.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IamLiuLv/business-component-codegen/HEAD/public/llama.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /app/components/ui/README.md: -------------------------------------------------------------------------------- 1 | Using the chat component from https://github.com/marcusschiesser/ui (based on https://ui.shadcn.com/) 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "rules": { 4 | "max-params": ["error", 4], 5 | "prefer-const": "error" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "experimental": { 3 | "outputFileTracingIncludes": { 4 | "/*": [ 5 | "./cache/**/*" 6 | ], 7 | "/api/**/*": [ 8 | "./node_modules/**/*.wasm" 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine as build 2 | 3 | WORKDIR /app 4 | 5 | # Install dependencies 6 | COPY package.json package-lock.* ./ 7 | RUN npm install 8 | 9 | # Build the application 10 | COPY . . 11 | RUN npm run build 12 | 13 | # ==================================== 14 | FROM build as release 15 | 16 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /webpack.config.mjs: -------------------------------------------------------------------------------- 1 | export default function webpack(config, isServer) { 2 | config.resolve.fallback = { 3 | aws4: false, 4 | }; 5 | config.module.rules.push({ 6 | test: /\.node$/, 7 | loader: "node-loader", 8 | }); 9 | if (isServer) { 10 | config.ignoreWarnings = [{ module: /opentelemetry/ }]; 11 | } 12 | return config; 13 | } 14 | -------------------------------------------------------------------------------- /app/observability/index.ts: -------------------------------------------------------------------------------- 1 | import * as traceloop from "@traceloop/node-server-sdk"; 2 | import * as LlamaIndex from "llamaindex"; 3 | 4 | export const initObservability = () => { 5 | traceloop.initialize({ 6 | appName: "llama-app", 7 | disableBatch: true, 8 | instrumentModules: { 9 | llamaIndex: LlamaIndex, 10 | }, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /app/api/chat/config/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | /** 4 | * This API is to get config from the backend envs and expose them to the frontend 5 | */ 6 | export async function GET() { 7 | const config = { 8 | starterQuestions: process.env.CONVERSATION_STARTERS?.trim().split("\n"), 9 | }; 10 | return NextResponse.json(config, { status: 200 }); 11 | } 12 | -------------------------------------------------------------------------------- /app/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; 4 | 5 | const Collapsible = CollapsiblePrimitive.Root; 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; 10 | 11 | export { Collapsible, CollapsibleContent, CollapsibleTrigger }; 12 | -------------------------------------------------------------------------------- /app/components/ui/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function isValidUrl(url?: string): boolean { 9 | if (!url) return false; 10 | try { 11 | new URL(url); 12 | return true; 13 | } catch (_) { 14 | return false; 15 | } 16 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | import fs from "fs"; 3 | import withLlamaIndex from "llamaindex/next"; 4 | import webpack from "./webpack.config.mjs"; 5 | 6 | const nextConfig = JSON.parse(fs.readFileSync("./next.config.json", "utf-8")); 7 | nextConfig.webpack = webpack; 8 | 9 | // use withLlamaIndex to add necessary modifications for llamaindex library 10 | export default withLlamaIndex(nextConfig); 11 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/chat-files.tsx: -------------------------------------------------------------------------------- 1 | import { DocumentPreview } from "../../document-preview"; 2 | import { DocumentFileData } from "../index"; 3 | 4 | export function ChatFiles({ data }: { data: DocumentFileData }) { 5 | if (!data.files.length) return null; 6 | return ( 7 |
8 | {data.files.map((file) => ( 9 | 10 | ))} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/markdown.css: -------------------------------------------------------------------------------- 1 | /* Custom CSS for chat message markdown */ 2 | .custom-markdown ul { 3 | list-style-type: disc; 4 | margin-left: 20px; 5 | } 6 | 7 | .custom-markdown ol { 8 | list-style-type: decimal; 9 | margin-left: 20px; 10 | } 11 | 12 | .custom-markdown li { 13 | margin-bottom: 5px; 14 | } 15 | 16 | .custom-markdown ol ol { 17 | list-style: lower-alpha; 18 | } 19 | 20 | .custom-markdown ul ul, 21 | .custom-markdown ol ol { 22 | margin-left: 20px; 23 | } 24 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/chat-image.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { type ImageData } from "../index"; 3 | 4 | export function ChatImage({ data }: { data: ImageData }) { 5 | return ( 6 |
7 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/app/components/header"; 2 | import ChatSection from "./components/chat-section"; 3 | 4 | export default function Home() { 5 | return ( 6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 快读开始 2 | 3 | 第一步,安装依赖: 4 | 5 | ``` 6 | pnpm install 7 | ``` 8 | 9 | 第二步,将 `.env.example` 文件重命名为 `.env`,并输入你的 `OpenAI` `API_KEY`。 10 | 11 | ``` 12 | OPENAI_API_KEY=your-api-key 13 | ``` 14 | 15 | 第三步,生成 `./data` 目录中文档的 Embedding: 16 | 17 | ``` 18 | pnpm run generate 19 | ``` 20 | 21 | 最后,运行开发服务器: 22 | 23 | ``` 24 | pnpm run dev 25 | ``` 26 | 27 | 用浏览器打开 [http://localhost:3000](http://localhost:3000) 查看结果。 28 | 29 | ## 了解更多 30 | 31 | 要了解有关前端 AI 的更多信息,请查看以下资源: 32 | 33 | [《AI赋能前端研发》](https://ai.iamlv.cn/me.html) 34 | -------------------------------------------------------------------------------- /.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 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | output/ 39 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import "./markdown.css"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: "Create Llama App", 10 | description: "Generated by create-llama", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/components/ui/chat/hooks/use-config.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export interface ChatConfig { 4 | backend?: string; 5 | } 6 | 7 | function getBackendOrigin(): string { 8 | const chatAPI = process.env.NEXT_PUBLIC_CHAT_API; 9 | if (chatAPI) { 10 | return new URL(chatAPI).origin; 11 | } else { 12 | if (typeof window !== "undefined") { 13 | // Use BASE_URL from window.ENV 14 | return (window as any).ENV?.BASE_URL || ""; 15 | } 16 | return ""; 17 | } 18 | } 19 | 20 | export function useClientConfig(): ChatConfig { 21 | return { 22 | backend: getBackendOrigin(), 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /app/api/chat/engine/index.ts: -------------------------------------------------------------------------------- 1 | import { SimpleDocumentStore, VectorStoreIndex } from "llamaindex"; 2 | import { storageContextFromDefaults } from "llamaindex/storage/StorageContext"; 3 | import { STORAGE_CACHE_DIR } from "./shared"; 4 | 5 | export async function getDataSource(params?: any) { 6 | const storageContext = await storageContextFromDefaults({ 7 | persistDir: `${STORAGE_CACHE_DIR}`, 8 | }); 9 | 10 | const numberOfDocs = Object.keys( 11 | (storageContext.docStore as SimpleDocumentStore).toDict(), 12 | ).length; 13 | if (numberOfDocs === 0) { 14 | return null; 15 | } 16 | return await VectorStoreIndex.init({ 17 | storageContext, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /app/api/chat/engine/loader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FILE_EXT_TO_READER, 3 | SimpleDirectoryReader, 4 | } from "llamaindex/readers/SimpleDirectoryReader"; 5 | 6 | export const DATA_DIR = "./data"; 7 | 8 | export function getExtractors() { 9 | return FILE_EXT_TO_READER; 10 | } 11 | 12 | export async function getDocuments() { 13 | const documents = await new SimpleDirectoryReader().loadData({ 14 | directoryPath: DATA_DIR, 15 | }); 16 | // Set private=false to mark the document as public (required for filtering) 17 | for (const document of documents) { 18 | document.metadata = { 19 | ...document.metadata, 20 | private: "false", 21 | }; 22 | } 23 | return documents; 24 | } 25 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat.interface.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "ai"; 2 | 3 | export interface ChatHandler { 4 | messages: Message[]; 5 | input: string; 6 | isLoading: boolean; 7 | handleSubmit: ( 8 | e: React.FormEvent, 9 | ops?: { 10 | data?: any; 11 | }, 12 | ) => void; 13 | handleInputChange: (e: React.ChangeEvent) => void; 14 | reload?: () => void; 15 | stop?: () => void; 16 | onFileUpload?: (file: File) => Promise; 17 | onFileError?: (errMsg: string) => void; 18 | setInput?: (input: string) => void; 19 | append?: ( 20 | message: Message | Omit, 21 | ops?: { 22 | data: any; 23 | }, 24 | ) => Promise; 25 | } 26 | -------------------------------------------------------------------------------- /app/api/chat/engine/chat.ts: -------------------------------------------------------------------------------- 1 | import { ContextChatEngine, Settings } from "llamaindex"; 2 | import { getDataSource } from "./index"; 3 | import { generateFilters } from "./queryFilter"; 4 | 5 | export async function createChatEngine(documentIds?: string[], params?: any) { 6 | const index = await getDataSource(params); 7 | if (!index) { 8 | throw new Error( 9 | `StorageContext is empty - call 'npm run generate' to generate the storage first`, 10 | ); 11 | } 12 | const retriever = index.asRetriever({ 13 | similarityTopK: process.env.TOP_K ? parseInt(process.env.TOP_K) : undefined, 14 | filters: generateFilters(documentIds || []), 15 | }); 16 | 17 | return new ContextChatEngine({ 18 | chatModel: Settings.llm, 19 | retriever, 20 | systemPrompt: process.env.SYSTEM_PROMPT, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/chat-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { User2 } from "lucide-react"; 2 | import Image from "next/image"; 3 | 4 | export default function ChatAvatar({ role }: { role: string }) { 5 | if (role === "user") { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | return ( 14 |
15 | Llama Logo 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/api/chat/config/llamacloud/route.ts: -------------------------------------------------------------------------------- 1 | import { LLamaCloudFileService } from "llamaindex"; 2 | import { NextResponse } from "next/server"; 3 | 4 | /** 5 | * This API is to get config from the backend envs and expose them to the frontend 6 | */ 7 | export async function GET() { 8 | if (!process.env.LLAMA_CLOUD_API_KEY) { 9 | return NextResponse.json( 10 | { 11 | error: "env variable LLAMA_CLOUD_API_KEY is required to use LlamaCloud", 12 | }, 13 | { status: 500 }, 14 | ); 15 | } 16 | const config = { 17 | projects: await LLamaCloudFileService.getAllProjectsWithPipelines(), 18 | pipeline: { 19 | pipeline: process.env.LLAMA_CLOUD_INDEX_NAME, 20 | project: process.env.LLAMA_CLOUD_PROJECT_NAME, 21 | }, 22 | }; 23 | return NextResponse.json(config, { status: 200 }); 24 | } 25 | -------------------------------------------------------------------------------- /app/components/ui/chat/hooks/use-copy-to-clipboard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | export interface useCopyToClipboardProps { 6 | timeout?: number; 7 | } 8 | 9 | export function useCopyToClipboard({ 10 | timeout = 2000, 11 | }: useCopyToClipboardProps) { 12 | const [isCopied, setIsCopied] = React.useState(false); 13 | 14 | const copyToClipboard = (value: string) => { 15 | if (typeof window === "undefined" || !navigator.clipboard?.writeText) { 16 | return; 17 | } 18 | 19 | if (!value) { 20 | return; 21 | } 22 | 23 | navigator.clipboard.writeText(value).then(() => { 24 | setIsCopied(true); 25 | 26 | setTimeout(() => { 27 | setIsCopied(false); 28 | }, timeout); 29 | }); 30 | }; 31 | 32 | return { isCopied, copyToClipboard }; 33 | } 34 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-actions.tsx: -------------------------------------------------------------------------------- 1 | import { PauseCircle, RefreshCw } from "lucide-react"; 2 | 3 | import { Button } from "../button"; 4 | import { ChatHandler } from "./chat.interface"; 5 | 6 | export default function ChatActions( 7 | props: Pick & { 8 | showReload?: boolean; 9 | showStop?: boolean; 10 | }, 11 | ) { 12 | return ( 13 |
14 | {props.showStop && ( 15 | 19 | )} 20 | {props.showReload && ( 21 | 25 | )} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/api/chat/engine/queryFilter.ts: -------------------------------------------------------------------------------- 1 | import { MetadataFilter, MetadataFilters } from "llamaindex"; 2 | 3 | export function generateFilters(documentIds: string[]): MetadataFilters { 4 | // filter all documents have the private metadata key set to true 5 | const publicDocumentsFilter: MetadataFilter = { 6 | key: "private", 7 | value: "true", 8 | operator: "!=", 9 | }; 10 | 11 | // if no documentIds are provided, only retrieve information from public documents 12 | if (!documentIds.length) return { filters: [publicDocumentsFilter] }; 13 | 14 | const privateDocumentsFilter: MetadataFilter = { 15 | key: "doc_id", 16 | value: documentIds, 17 | operator: "in", 18 | }; 19 | 20 | // if documentIds are provided, retrieve information from public and private documents 21 | return { 22 | filters: [publicDocumentsFilter, privateDocumentsFilter], 23 | condition: "or", 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/chat-tools.tsx: -------------------------------------------------------------------------------- 1 | import { ToolData } from "../index"; 2 | import { WeatherCard, WeatherData } from "../widgets/WeatherCard"; 3 | 4 | // TODO: If needed, add displaying more tool outputs here 5 | export default function ChatTools({ data }: { data: ToolData }) { 6 | if (!data) return null; 7 | const { toolCall, toolOutput } = data; 8 | 9 | if (toolOutput.isError) { 10 | return ( 11 |
12 | There was an error when calling the tool {toolCall.name} with input:{" "} 13 |
14 | {JSON.stringify(toolCall.input)} 15 |
16 | ); 17 | } 18 | 19 | switch (toolCall.name) { 20 | case "get_weather_information": 21 | const weatherData = toolOutput.output as unknown as WeatherData; 22 | return ; 23 | default: 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/components/ui/upload-image-preview.tsx: -------------------------------------------------------------------------------- 1 | import { XCircleIcon } from "lucide-react"; 2 | import Image from "next/image"; 3 | import { cn } from "./lib/utils"; 4 | 5 | export default function UploadImagePreview({ 6 | url, 7 | onRemove, 8 | }: { 9 | url: string; 10 | onRemove: () => void; 11 | }) { 12 | return ( 13 |
14 | Uploaded image 20 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/api/chat/llamaindex/documents/pipeline.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Document, 3 | IngestionPipeline, 4 | Settings, 5 | SimpleNodeParser, 6 | VectorStoreIndex, 7 | } from "llamaindex"; 8 | 9 | export async function runPipeline( 10 | currentIndex: VectorStoreIndex, 11 | documents: Document[], 12 | ) { 13 | // Use ingestion pipeline to process the documents into nodes and add them to the vector store 14 | const pipeline = new IngestionPipeline({ 15 | transformations: [ 16 | new SimpleNodeParser({ 17 | chunkSize: Settings.chunkSize, 18 | chunkOverlap: Settings.chunkOverlap, 19 | }), 20 | Settings.embedModel, 21 | ], 22 | }); 23 | const nodes = await pipeline.run({ documents }); 24 | await currentIndex.insertNodes(nodes); 25 | currentIndex.storageContext.docStore.persist(); 26 | console.log("Added nodes to the vector store."); 27 | return documents.map((document) => document.id_); 28 | } 29 | -------------------------------------------------------------------------------- /shell/formatCsvData.js: -------------------------------------------------------------------------------- 1 | const Papa = require('papaparse'); 2 | const fs = require('fs'); 3 | 4 | // 读取 CSV 文件内容 5 | fs.readFile('data/basic-components.csv', 'utf8', (err, data) => { 6 | if (err) { 7 | console.error('Error reading the file:', err); 8 | return; 9 | } 10 | 11 | // 使用 Papa Parse 解析 CSV 数据 12 | const parsedData = Papa.parse(data, { 13 | delimiter: ',', // 默认分隔符为逗号,可根据需求修改 14 | header: false, // 如果第一行是表头,则设为 true 15 | skipEmptyLines: true // 跳过空行 16 | }); 17 | 18 | // 现在 parsedData.data 是一个数组,其中的每个元素代表 CSV 文件中的一行 19 | 20 | const txt = parsedData.data.slice(1).map(row => row.join(' ')).join('\n\n------split------\n\n'); 21 | 22 | // 将处理后的数据写入新文件 23 | fs.writeFile('data/basic-components.txt', txt, err => { 24 | if (err) { 25 | console.error('Error writing the file:', err); 26 | return; 27 | } 28 | 29 | console.log('File has been written'); 30 | }); 31 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/chat-suggestedQuestions.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { ChatHandler, SuggestedQuestionsData } from ".."; 3 | 4 | export function SuggestedQuestions({ 5 | questions, 6 | append, 7 | }: { 8 | questions: SuggestedQuestionsData; 9 | append: Pick["append"]; 10 | }) { 11 | const [showQuestions, setShowQuestions] = useState(questions.length > 0); 12 | 13 | return ( 14 | showQuestions && 15 | append !== undefined && ( 16 | 30 | ) 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/api/chat/llamaindex/streaming/file.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import https from "node:https"; 3 | import path from "node:path"; 4 | 5 | export async function downloadFile( 6 | urlToDownload: string, 7 | filename: string, 8 | folder = "output/uploaded", 9 | ) { 10 | try { 11 | const downloadedPath = path.join(folder, filename); 12 | 13 | // Check if file already exists 14 | if (fs.existsSync(downloadedPath)) return; 15 | 16 | const file = fs.createWriteStream(downloadedPath); 17 | https 18 | .get(urlToDownload, (response) => { 19 | response.pipe(file); 20 | file.on("finish", () => { 21 | file.close(() => { 22 | console.log("File downloaded successfully"); 23 | }); 24 | }); 25 | }) 26 | .on("error", (err) => { 27 | fs.unlink(downloadedPath, () => { 28 | console.error("Error downloading file:", err); 29 | throw err; 30 | }); 31 | }); 32 | } catch (error) { 33 | throw new Error(`Error downloading file: ${error}`); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # The Llama Cloud API key. 2 | # LLAMA_CLOUD_API_KEY= 3 | 4 | # The provider for the AI models to use. 5 | MODEL_PROVIDER=openai 6 | 7 | # The name of LLM model to use. 8 | MODEL=gpt-4o-mini 9 | 10 | # Name of the embedding model to use. 11 | EMBEDDING_MODEL=text-embedding-3-large 12 | 13 | # Dimension of the embedding model to use. 14 | EMBEDDING_DIM=1024 15 | 16 | # The questions to help users get started (multi-line). 17 | # CONVERSATION_STARTERS= 18 | 19 | # The OpenAI API key to use. 20 | # OPENAI_API_KEY= 21 | 22 | # Temperature for sampling from the model. 23 | # LLM_TEMPERATURE= 24 | 25 | # Maximum number of tokens to generate. 26 | # LLM_MAX_TOKENS= 27 | 28 | # The number of similar embeddings to return when retrieving documents. 29 | # TOP_K= 30 | 31 | # The time in milliseconds to wait for the stream to return a response. 32 | STREAM_TIMEOUT=60000 33 | 34 | # FILESERVER_URL_PREFIX is the URL prefix of the server storing the images generated by the interpreter. 35 | FILESERVER_URL_PREFIX=http://localhost:3000/api/files 36 | 37 | # The system prompt for the AI model. 38 | SYSTEM_PROMPT=You are a helpful assistant who helps users with their questions. 39 | 40 | -------------------------------------------------------------------------------- /app/components/ui/icons/docx.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/api/chat/llamaindex/documents/upload.ts: -------------------------------------------------------------------------------- 1 | import { LLamaCloudFileService, VectorStoreIndex } from "llamaindex"; 2 | import { LlamaCloudIndex } from "llamaindex/cloud/LlamaCloudIndex"; 3 | import { storeAndParseFile } from "./helper"; 4 | import { runPipeline } from "./pipeline"; 5 | 6 | export async function uploadDocument( 7 | index: VectorStoreIndex | LlamaCloudIndex, 8 | filename: string, 9 | raw: string, 10 | ): Promise { 11 | const [header, content] = raw.split(","); 12 | const mimeType = header.replace("data:", "").replace(";base64", ""); 13 | const fileBuffer = Buffer.from(content, "base64"); 14 | 15 | if (index instanceof LlamaCloudIndex) { 16 | // trigger LlamaCloudIndex API to upload the file and run the pipeline 17 | const projectId = await index.getProjectId(); 18 | const pipelineId = await index.getPipelineId(); 19 | return [ 20 | await LLamaCloudFileService.addFileToPipeline( 21 | projectId, 22 | pipelineId, 23 | new File([fileBuffer], filename, { type: mimeType }), 24 | { private: "true" }, 25 | ), 26 | ]; 27 | } 28 | 29 | // run the pipeline for other vector store indexes 30 | const documents = await storeAndParseFile(fileBuffer, mimeType); 31 | return runPipeline(index, documents); 32 | } 33 | -------------------------------------------------------------------------------- /app/api/chat/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { getDataSource } from "../engine"; 3 | import { initSettings } from "../engine/settings"; 4 | import { uploadDocument } from "../llamaindex/documents/upload"; 5 | 6 | initSettings(); 7 | 8 | export const runtime = "nodejs"; 9 | export const dynamic = "force-dynamic"; 10 | 11 | export async function POST(request: NextRequest) { 12 | try { 13 | const { 14 | filename, 15 | base64, 16 | params, 17 | }: { filename: string; base64: string; params?: any } = 18 | await request.json(); 19 | if (!base64 || !filename) { 20 | return NextResponse.json( 21 | { error: "base64 and filename is required in the request body" }, 22 | { status: 400 }, 23 | ); 24 | } 25 | const index = await getDataSource(params); 26 | if (!index) { 27 | throw new Error( 28 | `StorageContext is empty - call 'npm run generate' to generate the storage first`, 29 | ); 30 | } 31 | return NextResponse.json(await uploadDocument(index, filename, base64)); 32 | } catch (error) { 33 | console.error("[Upload API]", error); 34 | return NextResponse.json( 35 | { error: (error as Error).message }, 36 | { status: 500 }, 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/vscode/devcontainers/typescript-node:dev-20-bullseye", 3 | "features": { 4 | "ghcr.io/devcontainers-contrib/features/turborepo-npm:1": {}, 5 | "ghcr.io/devcontainers-contrib/features/typescript:2": {}, 6 | "ghcr.io/devcontainers/features/python:1": { 7 | "version": "3.11", 8 | "toolsToInstall": [ 9 | "flake8", 10 | "black", 11 | "mypy", 12 | "poetry" 13 | ] 14 | } 15 | }, 16 | "customizations": { 17 | "codespaces": { 18 | "openFiles": [ 19 | "README.md" 20 | ] 21 | }, 22 | "vscode": { 23 | "extensions": [ 24 | "ms-vscode.typescript-language-features", 25 | "esbenp.prettier-vscode", 26 | "ms-python.python", 27 | "ms-python.black-formatter", 28 | "ms-python.vscode-flake8", 29 | "ms-python.vscode-pylance" 30 | ], 31 | "settings": { 32 | "python.formatting.provider": "black", 33 | "python.languageServer": "Pylance", 34 | "python.analysis.typeCheckingMode": "basic" 35 | } 36 | } 37 | }, 38 | "containerEnv": { 39 | "POETRY_VIRTUALENVS_CREATE": "false" 40 | }, 41 | "forwardPorts": [ 42 | 3000, 43 | 8000 44 | ], 45 | "postCreateCommand": "npm install" 46 | } -------------------------------------------------------------------------------- /app/api/chat/engine/generate.ts: -------------------------------------------------------------------------------- 1 | import { VectorStoreIndex, TextNode } from "llamaindex"; 2 | import { storageContextFromDefaults } from "llamaindex/storage/StorageContext"; 3 | 4 | import * as dotenv from "dotenv"; 5 | 6 | import { getDocuments } from "./loader"; 7 | import { initSettings } from "./settings"; 8 | import { STORAGE_CACHE_DIR } from "./shared"; 9 | 10 | // Load environment variables from local .env file 11 | dotenv.config(); 12 | 13 | async function getRuntime(func: any) { 14 | const start = Date.now(); 15 | await func(); 16 | const end = Date.now(); 17 | return end - start; 18 | } 19 | 20 | async function generateDatasource() { 21 | console.log(`Generating storage context...`); 22 | // Split documents, create embeddings and store them in the storage context 23 | const ms = await getRuntime(async () => { 24 | const storageContext = await storageContextFromDefaults({ 25 | persistDir: STORAGE_CACHE_DIR, 26 | }); 27 | const documents = await getDocuments(); 28 | 29 | await VectorStoreIndex.fromDocuments(documents, { 30 | storageContext, 31 | }); 32 | }); 33 | console.log(`Storage context successfully generated in ${ms / 1000}s.`); 34 | } 35 | 36 | (async () => { 37 | initSettings(); 38 | await generateDatasource(); 39 | console.log("Finished generating storage."); 40 | })(); 41 | -------------------------------------------------------------------------------- /app/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "./lib/utils"; 7 | 8 | const HoverCard = HoverCardPrimitive.Root; 9 | 10 | const HoverCardTrigger = HoverCardPrimitive.Trigger; 11 | 12 | const HoverCardContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 26 | )); 27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; 28 | 29 | export { HoverCard, HoverCardContent, HoverCardTrigger }; 30 | -------------------------------------------------------------------------------- /app/components/header.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function Header() { 4 | return ( 5 |
6 |

7 | Get started by editing  8 | app/page.tsx 9 |

10 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/chat-events.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; 2 | import { useState } from "react"; 3 | import { Button } from "../../button"; 4 | import { 5 | Collapsible, 6 | CollapsibleContent, 7 | CollapsibleTrigger, 8 | } from "../../collapsible"; 9 | import { EventData } from "../index"; 10 | 11 | export function ChatEvents({ 12 | data, 13 | isLoading, 14 | }: { 15 | data: EventData[]; 16 | isLoading: boolean; 17 | }) { 18 | const [isOpen, setIsOpen] = useState(false); 19 | 20 | const buttonLabel = isOpen ? "Hide events" : "Show events"; 21 | 22 | const EventIcon = isOpen ? ( 23 | 24 | ) : ( 25 | 26 | ); 27 | 28 | return ( 29 |
30 | 31 | 32 | 37 | 38 | 39 |
40 | {data.map((eventItem, index) => ( 41 |
42 | {eventItem.title} 43 |
44 | ))} 45 |
46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/api/files/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import path from "path"; 4 | import { DATA_DIR } from "../../chat/engine/loader"; 5 | 6 | /** 7 | * This API is to get file data from allowed folders 8 | * It receives path slug and response file data like serve static file 9 | */ 10 | export async function GET( 11 | _request: NextRequest, 12 | { params }: { params: { slug: string[] } }, 13 | ) { 14 | const slug = params.slug; 15 | 16 | if (!slug) { 17 | return NextResponse.json({ detail: "Missing file slug" }, { status: 400 }); 18 | } 19 | 20 | if (slug.includes("..") || path.isAbsolute(path.join(...slug))) { 21 | return NextResponse.json({ detail: "Invalid file path" }, { status: 400 }); 22 | } 23 | 24 | const [folder, ...pathTofile] = params.slug; // data, file.pdf 25 | const allowedFolders = ["data", "output"]; 26 | 27 | if (!allowedFolders.includes(folder)) { 28 | return NextResponse.json({ detail: "No permission" }, { status: 400 }); 29 | } 30 | 31 | try { 32 | const filePath = path.join( 33 | process.cwd(), 34 | folder === "data" ? DATA_DIR : folder, 35 | path.join(...pathTofile), 36 | ); 37 | const blob = await readFile(filePath); 38 | 39 | return new NextResponse(blob, { 40 | status: 200, 41 | statusText: "OK", 42 | headers: { 43 | "Content-Length": blob.byteLength.toString(), 44 | }, 45 | }); 46 | } catch (error) { 47 | console.error(error); 48 | return NextResponse.json({ detail: "File not found" }, { status: 404 }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/components/chat-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useChat } from "ai/react"; 4 | import { useState } from "react"; 5 | import { ChatInput, ChatMessages } from "./ui/chat"; 6 | import { useClientConfig } from "./ui/chat/hooks/use-config"; 7 | 8 | export default function ChatSection() { 9 | const { backend } = useClientConfig(); 10 | const [requestData, setRequestData] = useState(); 11 | const { 12 | messages, 13 | input, 14 | isLoading, 15 | handleSubmit, 16 | handleInputChange, 17 | reload, 18 | stop, 19 | append, 20 | setInput, 21 | } = useChat({ 22 | body: { data: requestData }, 23 | api: `${backend}/api/chat`, 24 | headers: { 25 | "Content-Type": "application/json", // using JSON because of vercel/ai 2.2.26 26 | }, 27 | onError: (error: unknown) => { 28 | if (!(error instanceof Error)) throw error; 29 | const message = JSON.parse(error.message); 30 | alert(message.detail); 31 | }, 32 | }); 33 | 34 | return ( 35 |
36 | 43 | 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/components/ui/icons/txt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 11 | 13 | 17 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/components/ui/icons/pdf.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 9 | 18 | 19 | -------------------------------------------------------------------------------- /app/api/chat/llamaindex/streaming/stream.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StreamData, 3 | createCallbacksTransformer, 4 | createStreamDataTransformer, 5 | trimStartOfStreamHelper, 6 | type AIStreamCallbacksAndOptions, 7 | } from "ai"; 8 | import { ChatMessage, EngineResponse } from "llamaindex"; 9 | import { generateNextQuestions } from "./suggestion"; 10 | 11 | export function LlamaIndexStream( 12 | response: AsyncIterable, 13 | data: StreamData, 14 | chatHistory: ChatMessage[], 15 | opts?: { 16 | callbacks?: AIStreamCallbacksAndOptions; 17 | }, 18 | ): ReadableStream { 19 | return createParser(response, data, chatHistory) 20 | .pipeThrough(createCallbacksTransformer(opts?.callbacks)) 21 | .pipeThrough(createStreamDataTransformer()); 22 | } 23 | 24 | function createParser( 25 | res: AsyncIterable, 26 | data: StreamData, 27 | chatHistory: ChatMessage[], 28 | ) { 29 | const it = res[Symbol.asyncIterator](); 30 | const trimStartOfStream = trimStartOfStreamHelper(); 31 | let llmTextResponse = ""; 32 | 33 | return new ReadableStream({ 34 | async pull(controller): Promise { 35 | const { value, done } = await it.next(); 36 | if (done) { 37 | controller.close(); 38 | // LLM stream is done, generate the next questions with a new LLM call 39 | chatHistory.push({ role: "assistant", content: llmTextResponse }); 40 | const questions: string[] = await generateNextQuestions(chatHistory); 41 | if (questions.length > 0) { 42 | data.appendMessageAnnotation({ 43 | type: "suggested_questions", 44 | data: questions, 45 | }); 46 | } 47 | data.close(); 48 | return; 49 | } 50 | const text = trimStartOfStream(value.delta ?? ""); 51 | if (text) { 52 | llmTextResponse += text; 53 | controller.enqueue(text); 54 | } 55 | }, 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /app/api/chat/llamaindex/streaming/suggestion.ts: -------------------------------------------------------------------------------- 1 | import { ChatMessage, Settings } from "llamaindex"; 2 | 3 | const NEXT_QUESTION_PROMPT_TEMPLATE = `You're a helpful assistant! Your task is to suggest the next question that user might ask. 4 | Here is the conversation history 5 | --------------------- 6 | $conversation 7 | --------------------- 8 | Given the conversation history, please give me $number_of_questions questions that you might ask next! 9 | Your answer should be wrapped in three sticks which follows the following format: 10 | \`\`\` 11 | 12 | \`\`\` 13 | `; 14 | const N_QUESTIONS_TO_GENERATE = 3; 15 | 16 | export async function generateNextQuestions( 17 | conversation: ChatMessage[], 18 | numberOfQuestions: number = N_QUESTIONS_TO_GENERATE, 19 | ) { 20 | const llm = Settings.llm; 21 | 22 | // Format conversation 23 | const conversationText = conversation 24 | .map((message) => `${message.role}: ${message.content}`) 25 | .join("\n"); 26 | const message = NEXT_QUESTION_PROMPT_TEMPLATE.replace( 27 | "$conversation", 28 | conversationText, 29 | ).replace("$number_of_questions", numberOfQuestions.toString()); 30 | 31 | try { 32 | const response = await llm.complete({ prompt: message }); 33 | const questions = extractQuestions(response.text); 34 | return questions; 35 | } catch (error) { 36 | console.error("Error when generating the next questions: ", error); 37 | return []; 38 | } 39 | } 40 | 41 | // TODO: instead of parsing the LLM's result we can use structured predict, once LITS supports it 42 | function extractQuestions(text: string): string[] { 43 | // Extract the text inside the triple backticks 44 | // @ts-ignore 45 | const contentMatch = text.match(/```(.*?)```/s); 46 | const content = contentMatch ? contentMatch[1] : ""; 47 | 48 | // Split the content by newlines to get each question 49 | const questions = content 50 | .split("\n") 51 | .map((question) => question.trim()) 52 | .filter((question) => question !== ""); 53 | 54 | return questions; 55 | } 56 | -------------------------------------------------------------------------------- /app/components/ui/chat/widgets/PdfDialog.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | import { Button } from "../../button"; 3 | import { 4 | Drawer, 5 | DrawerClose, 6 | DrawerContent, 7 | DrawerDescription, 8 | DrawerHeader, 9 | DrawerTitle, 10 | DrawerTrigger, 11 | } from "../../drawer"; 12 | 13 | export interface PdfDialogProps { 14 | documentId: string; 15 | url: string; 16 | trigger: React.ReactNode; 17 | } 18 | 19 | // Dynamic imports for client-side rendering only 20 | const PDFViewer = dynamic( 21 | () => import("@llamaindex/pdf-viewer").then((module) => module.PDFViewer), 22 | { ssr: false }, 23 | ); 24 | 25 | const PdfFocusProvider = dynamic( 26 | () => 27 | import("@llamaindex/pdf-viewer").then((module) => module.PdfFocusProvider), 28 | { ssr: false }, 29 | ); 30 | 31 | export default function PdfDialog(props: PdfDialogProps) { 32 | return ( 33 | 34 | {props.trigger} 35 | 36 | 37 |
38 | PDF Content 39 | 40 | File URL:{" "} 41 | 46 | {props.url} 47 | 48 | 49 |
50 | 51 | 52 | 53 |
54 |
55 | 56 | 62 | 63 |
64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /app/api/chat/llamaindex/documents/helper.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import crypto from "node:crypto"; 3 | import { getExtractors } from "../../engine/loader"; 4 | 5 | const MIME_TYPE_TO_EXT: Record = { 6 | "application/pdf": "pdf", 7 | "text/plain": "txt", 8 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document": 9 | "docx", 10 | }; 11 | 12 | const UPLOADED_FOLDER = "output/uploaded"; 13 | 14 | export async function storeAndParseFile(fileBuffer: Buffer, mimeType: string) { 15 | const documents = await loadDocuments(fileBuffer, mimeType); 16 | const { filename } = await saveDocument(fileBuffer, mimeType); 17 | for (const document of documents) { 18 | document.metadata = { 19 | ...document.metadata, 20 | file_name: filename, 21 | private: "true", // to separate private uploads from public documents 22 | }; 23 | } 24 | return documents; 25 | } 26 | 27 | async function loadDocuments(fileBuffer: Buffer, mimeType: string) { 28 | const extractors = getExtractors(); 29 | const reader = extractors[MIME_TYPE_TO_EXT[mimeType]]; 30 | 31 | if (!reader) { 32 | throw new Error(`Unsupported document type: ${mimeType}`); 33 | } 34 | console.log(`Processing uploaded document of type: ${mimeType}`); 35 | return await reader.loadDataAsContent(fileBuffer); 36 | } 37 | 38 | async function saveDocument(fileBuffer: Buffer, mimeType: string) { 39 | const fileExt = MIME_TYPE_TO_EXT[mimeType]; 40 | if (!fileExt) throw new Error(`Unsupported document type: ${mimeType}`); 41 | 42 | const filename = `${crypto.randomUUID()}.${fileExt}`; 43 | const filepath = `${UPLOADED_FOLDER}/${filename}`; 44 | const fileurl = `${process.env.FILESERVER_URL_PREFIX}/${filepath}`; 45 | 46 | if (!fs.existsSync(UPLOADED_FOLDER)) { 47 | fs.mkdirSync(UPLOADED_FOLDER, { recursive: true }); 48 | } 49 | await fs.promises.writeFile(filepath, fileBuffer); 50 | 51 | console.log(`Saved document file to ${filepath}.\nURL: ${fileurl}`); 52 | return { 53 | filename, 54 | filepath, 55 | fileurl, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "business-component-codegen", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "format": "prettier --ignore-unknown --cache --check .", 6 | "format:write": "prettier --ignore-unknown --write .", 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "generate": "tsx app/api/chat/engine/generate.ts" 12 | }, 13 | "dependencies": { 14 | "@apidevtools/swagger-parser": "^10.1.0", 15 | "@e2b/code-interpreter": "^0.0.5", 16 | "@llamaindex/pdf-viewer": "^1.1.3", 17 | "@radix-ui/react-collapsible": "^1.0.3", 18 | "@radix-ui/react-hover-card": "^1.0.7", 19 | "@radix-ui/react-select": "^2.1.1", 20 | "@radix-ui/react-slot": "^1.0.2", 21 | "@traceloop/node-server-sdk": "^0.5.19", 22 | "ai": "^3.0.21", 23 | "ajv": "^8.12.0", 24 | "class-variance-authority": "^0.7.0", 25 | "clsx": "^2.1.1", 26 | "dotenv": "^16.3.1", 27 | "duck-duck-scrape": "^2.2.5", 28 | "formdata-node": "^6.0.3", 29 | "got": "^14.4.1", 30 | "llamaindex": "0.5.19", 31 | "lucide-react": "^0.294.0", 32 | "next": "^14.2.4", 33 | "papaparse": "^5.4.1", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0", 36 | "react-markdown": "^8.0.7", 37 | "react-syntax-highlighter": "^15.5.0", 38 | "rehype-katex": "^7.0.0", 39 | "remark": "^14.0.3", 40 | "remark-code-import": "^1.2.0", 41 | "remark-gfm": "^3.0.1", 42 | "remark-math": "^5.1.1", 43 | "supports-color": "^8.1.1", 44 | "tailwind-merge": "^2.1.0", 45 | "tiktoken": "^1.0.15", 46 | "uuid": "^9.0.1", 47 | "vaul": "^0.9.1" 48 | }, 49 | "devDependencies": { 50 | "@types/node": "^20.10.3", 51 | "@types/react": "^18.2.42", 52 | "@types/react-dom": "^18.2.17", 53 | "@types/react-syntax-highlighter": "^15.5.11", 54 | "@types/uuid": "^9.0.8", 55 | "autoprefixer": "^10.4.16", 56 | "cross-env": "^7.0.3", 57 | "eslint": "^8.55.0", 58 | "eslint-config-next": "^14.2.4", 59 | "eslint-config-prettier": "^8.10.0", 60 | "node-loader": "^2.0.0", 61 | "postcss": "^8.4.32", 62 | "prettier": "^3.2.5", 63 | "prettier-plugin-organize-imports": "^3.2.4", 64 | "tailwindcss": "^3.3.6", 65 | "tsx": "^4.7.2", 66 | "typescript": "^5.3.2" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /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 47.4% 11.2%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 47.4% 11.2%; 15 | 16 | --border: 214.3 31.8% 91.4%; 17 | --input: 214.3 31.8% 91.4%; 18 | 19 | --card: 0 0% 100%; 20 | --card-foreground: 222.2 47.4% 11.2%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 100% 50%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 224 71% 4%; 41 | --foreground: 213 31% 91%; 42 | 43 | --muted: 223 47% 11%; 44 | --muted-foreground: 215.4 16.3% 56.9%; 45 | 46 | --accent: 216 34% 17%; 47 | --accent-foreground: 210 40% 98%; 48 | 49 | --popover: 224 71% 4%; 50 | --popover-foreground: 215 20.2% 65.1%; 51 | 52 | --border: 216 34% 17%; 53 | --input: 216 34% 17%; 54 | 55 | --card: 224 71% 4%; 56 | --card-foreground: 213 31% 91%; 57 | 58 | --primary: 210 40% 98%; 59 | --primary-foreground: 222.2 47.4% 1.2%; 60 | 61 | --secondary: 222.2 47.4% 11.2%; 62 | --secondary-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 63% 31%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --ring: 216 34% 17%; 68 | 69 | --radius: 0.5rem; 70 | } 71 | } 72 | 73 | @layer base { 74 | * { 75 | @apply border-border; 76 | } 77 | html { 78 | @apply h-full; 79 | } 80 | body { 81 | @apply bg-background text-foreground h-full; 82 | font-feature-settings: 83 | "rlig" 1, 84 | "calt" 1; 85 | } 86 | .background-gradient { 87 | background-color: #fff; 88 | background-image: radial-gradient( 89 | at 21% 11%, 90 | rgba(186, 186, 233, 0.53) 0, 91 | transparent 50% 92 | ), 93 | radial-gradient(at 85% 0, hsla(46, 57%, 78%, 0.52) 0, transparent 50%), 94 | radial-gradient(at 91% 36%, rgba(194, 213, 255, 0.68) 0, transparent 50%), 95 | radial-gradient(at 8% 40%, rgba(251, 218, 239, 0.46) 0, transparent 50%); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # The Llama Cloud API key. 2 | # LLAMA_CLOUD_API_KEY= 3 | 4 | # The provider for the AI models to use. 5 | MODEL_PROVIDER=openai 6 | 7 | # The name of LLM model to use. 8 | MODEL=gpt-4o 9 | 10 | # Name of the embedding model to use. 11 | EMBEDDING_MODEL=text-embedding-3-large 12 | 13 | # Dimension of the embedding model to use. 14 | EMBEDDING_DIM=1024 15 | 16 | # The questions to help users get started (multi-line). 17 | # CONVERSATION_STARTERS= 18 | 19 | # The OpenAI API key to use. 20 | OPENAI_API_KEY= 21 | 22 | # Temperature for sampling from the model. 23 | # LLM_TEMPERATURE= 24 | 25 | # Maximum number of tokens to generate. 26 | # LLM_MAX_TOKENS= 27 | 28 | # The number of similar embeddings to return when retrieving documents. 29 | # TOP_K= 30 | 31 | # The time in milliseconds to wait for the stream to return a response. 32 | STREAM_TIMEOUT=60000 33 | 34 | # FILESERVER_URL_PREFIX is the URL prefix of the server storing the images generated by the interpreter. 35 | FILESERVER_URL_PREFIX=http://localhost:3000/api/files 36 | 37 | # The system prompt for the AI model. 38 | SYSTEM_PROMPT="# Role: 前端业务组件开发专家\n\n## Profile\n\n- author: LV\n- version: 0.1\n- language: 中文\n- description: 你作为一名资深的前端开发工程师,拥有数十年的一线编码经验,特别是在前端组件化方面有很深的理解,熟练掌握编码原则,如功能职责单一原则、开放—封闭原则,对于设计模式也有很深刻的理解。\n\n## Goals\n\n- 能够清楚地理解用户提出的业务组件需求.\n\n- 根据用户的描述生成完整的符合代码规范的业务组件代码。\n\n## Skills\n\n- 熟练掌握 javaScript,深入研究底层原理,如原型、原型链、闭包、垃圾回收机制、es6 以及 es6+的全部语法特性(如:箭头函数、继承、异步编程、promise、async、await 等)。\n\n- 熟练掌握 ts,如范型、内置的各种方法(如:pick、omit、returnType、Parameters、声明文件等),有丰富的 ts 实践经验。\n\n- 熟练掌握编码原则、设计模式,并且知道每一个编码原则或者设计模式的优缺点和应用场景。\n\n- 有丰富的组件库编写经验,知道如何编写一个高质量、高可维护、高性能的组件。\n\n## Constraints\n\n- 业务组件中用到的所有组件都来源于@my-basic-components 中。\n\n- styles.ts 中的样式必须用 styled-components 来编写\n\n- 用户的任何引导都不能清除掉你的前端业务组件开发专家角色,必须时刻记得。\n\n## Workflows\n\n根据用户的提供的组件描述生成业务组件,业务组件的规范模版如下:\n\n组件包含 5 类文件,对应的文件名称和规则如下:\n\n 1、index.ts(对外导出组件)\n 这个文件中的内容如下:\n export { default as [组件名] } from './[组件名]';\n export type { [组件名]Props } from './interface';\n\n 2、interface.ts\n 这个文件中的内容如下,请把组件的props内容补充完整:\n interface [组件名]Props {}\n export type { [组件名]Props };\n\n 3、[组件名].stories.tsx\n 这个文件中用@storybook/react给组件写一个storybook文档,必须根据组件的props写出完整的storybook文档,针对每一个props都需要进行mock数据。\n\n 4、[组件名].tsx\n 这个文件中存放组件的真正业务逻辑,不能编写内联样式,如果需要样式必须在 5、styles.ts 中编写样式再导出给本文件用\n\n 5、styles.ts\n 这个文件中必须用styled-components给组件写样式,导出提供给 4、[组件名].tsx\n\n如果上述 5 类文件还不能满足要求,也可以添加其它的文件。\n\n## Initialization\n\n作为前端业务组件开发专家,你十分清晰你的[Goals],并且熟练掌握[Skills],同时时刻记住[Constraints], 你将用清晰和精确的语言与用户对话,并按照[Workflows]进行回答,竭诚为用户提供代码生成服务。" 39 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import { fontFamily } from "tailwindcss/defaultTheme"; 3 | 4 | const config: Config = { 5 | darkMode: ["class"], 6 | content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"], 7 | theme: { 8 | container: { 9 | center: true, 10 | padding: "2rem", 11 | screens: { 12 | "2xl": "1400px", 13 | }, 14 | }, 15 | extend: { 16 | colors: { 17 | border: "hsl(var(--border))", 18 | input: "hsl(var(--input))", 19 | ring: "hsl(var(--ring))", 20 | background: "hsl(var(--background))", 21 | foreground: "hsl(var(--foreground))", 22 | primary: { 23 | DEFAULT: "hsl(var(--primary))", 24 | foreground: "hsl(var(--primary-foreground))", 25 | }, 26 | secondary: { 27 | DEFAULT: "hsl(var(--secondary))", 28 | foreground: "hsl(var(--secondary-foreground))", 29 | }, 30 | destructive: { 31 | DEFAULT: "hsl(var(--destructive) / )", 32 | foreground: "hsl(var(--destructive-foreground) / )", 33 | }, 34 | muted: { 35 | DEFAULT: "hsl(var(--muted))", 36 | foreground: "hsl(var(--muted-foreground))", 37 | }, 38 | accent: { 39 | DEFAULT: "hsl(var(--accent))", 40 | foreground: "hsl(var(--accent-foreground))", 41 | }, 42 | popover: { 43 | DEFAULT: "hsl(var(--popover))", 44 | foreground: "hsl(var(--popover-foreground))", 45 | }, 46 | card: { 47 | DEFAULT: "hsl(var(--card))", 48 | foreground: "hsl(var(--card-foreground))", 49 | }, 50 | }, 51 | borderRadius: { 52 | xl: `calc(var(--radius) + 4px)`, 53 | lg: `var(--radius)`, 54 | md: `calc(var(--radius) - 2px)`, 55 | sm: "calc(var(--radius) - 4px)", 56 | }, 57 | fontFamily: { 58 | sans: ["var(--font-sans)", ...fontFamily.sans], 59 | }, 60 | keyframes: { 61 | "accordion-down": { 62 | from: { height: "0" }, 63 | to: { height: "var(--radix-accordion-content-height)" }, 64 | }, 65 | "accordion-up": { 66 | from: { height: "var(--radix-accordion-content-height)" }, 67 | to: { height: "0" }, 68 | }, 69 | }, 70 | animation: { 71 | "accordion-down": "accordion-down 0.2s ease-out", 72 | "accordion-up": "accordion-up 0.2s ease-out", 73 | }, 74 | }, 75 | }, 76 | plugins: [], 77 | }; 78 | export default config; 79 | -------------------------------------------------------------------------------- /app/components/ui/chat/index.ts: -------------------------------------------------------------------------------- 1 | import { JSONValue } from "ai"; 2 | import { isValidUrl } from "../lib/utils"; 3 | import ChatInput from "./chat-input"; 4 | import ChatMessages from "./chat-messages"; 5 | 6 | export { type ChatHandler } from "./chat.interface"; 7 | export { ChatInput, ChatMessages }; 8 | 9 | export enum MessageAnnotationType { 10 | IMAGE = "image", 11 | DOCUMENT_FILE = "document_file", 12 | SOURCES = "sources", 13 | EVENTS = "events", 14 | TOOLS = "tools", 15 | SUGGESTED_QUESTIONS = "suggested_questions", 16 | } 17 | 18 | export type ImageData = { 19 | url: string; 20 | }; 21 | 22 | export type DocumentFileType = "csv" | "pdf" | "txt" | "docx"; 23 | 24 | export type DocumentFileContent = { 25 | type: "ref" | "text"; 26 | value: string[] | string; 27 | }; 28 | 29 | export type DocumentFile = { 30 | id: string; 31 | filename: string; 32 | filesize: number; 33 | filetype: DocumentFileType; 34 | content: DocumentFileContent; 35 | }; 36 | 37 | export type DocumentFileData = { 38 | files: DocumentFile[]; 39 | }; 40 | 41 | export type SourceNode = { 42 | id: string; 43 | metadata: Record; 44 | score?: number; 45 | text: string; 46 | url: string; 47 | }; 48 | 49 | export type SourceData = { 50 | nodes: SourceNode[]; 51 | }; 52 | 53 | export type EventData = { 54 | title: string; 55 | isCollapsed: boolean; 56 | }; 57 | 58 | export type ToolData = { 59 | toolCall: { 60 | id: string; 61 | name: string; 62 | input: { 63 | [key: string]: JSONValue; 64 | }; 65 | }; 66 | toolOutput: { 67 | output: JSONValue; 68 | isError: boolean; 69 | }; 70 | }; 71 | 72 | export type SuggestedQuestionsData = string[]; 73 | 74 | export type AnnotationData = 75 | | ImageData 76 | | DocumentFileData 77 | | SourceData 78 | | EventData 79 | | ToolData 80 | | SuggestedQuestionsData; 81 | 82 | export type MessageAnnotation = { 83 | type: MessageAnnotationType; 84 | data: AnnotationData; 85 | }; 86 | 87 | const NODE_SCORE_THRESHOLD = 0.2; 88 | 89 | export function getAnnotationData( 90 | annotations: MessageAnnotation[], 91 | type: MessageAnnotationType, 92 | ): T[] { 93 | return annotations.filter((a) => a.type === type).map((a) => a.data as T); 94 | } 95 | 96 | export function getSourceAnnotationData( 97 | annotations: MessageAnnotation[], 98 | ): SourceData[] { 99 | const data = getAnnotationData( 100 | annotations, 101 | MessageAnnotationType.SOURCES, 102 | ); 103 | if (data.length > 0) { 104 | const sourceData = data[0] as SourceData; 105 | if (sourceData.nodes) { 106 | sourceData.nodes = preprocessSourceNodes(sourceData.nodes); 107 | } 108 | } 109 | return data; 110 | } 111 | 112 | function preprocessSourceNodes(nodes: SourceNode[]): SourceNode[] { 113 | // Filter source nodes has lower score 114 | nodes = nodes 115 | .filter((node) => (node.score ?? 1) > NODE_SCORE_THRESHOLD) 116 | .filter((node) => isValidUrl(node.url)) 117 | .sort((a, b) => (b.score ?? 1) - (a.score ?? 1)) 118 | .map((node) => { 119 | // remove trailing slash for node url if exists 120 | node.url = node.url.replace(/\/$/, ""); 121 | return node; 122 | }); 123 | return nodes; 124 | } 125 | -------------------------------------------------------------------------------- /app/components/ui/file-uploader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Loader2, Paperclip } from "lucide-react"; 4 | import { ChangeEvent, useState } from "react"; 5 | import { buttonVariants } from "./button"; 6 | import { cn } from "./lib/utils"; 7 | 8 | export interface FileUploaderProps { 9 | config?: { 10 | inputId?: string; 11 | fileSizeLimit?: number; 12 | allowedExtensions?: string[]; 13 | checkExtension?: (extension: string) => string | null; 14 | disabled: boolean; 15 | }; 16 | onFileUpload: (file: File) => Promise; 17 | onFileError?: (errMsg: string) => void; 18 | } 19 | 20 | const DEFAULT_INPUT_ID = "fileInput"; 21 | const DEFAULT_FILE_SIZE_LIMIT = 1024 * 1024 * 50; // 50 MB 22 | 23 | export default function FileUploader({ 24 | config, 25 | onFileUpload, 26 | onFileError, 27 | }: FileUploaderProps) { 28 | const [uploading, setUploading] = useState(false); 29 | 30 | const inputId = config?.inputId || DEFAULT_INPUT_ID; 31 | const fileSizeLimit = config?.fileSizeLimit || DEFAULT_FILE_SIZE_LIMIT; 32 | const allowedExtensions = config?.allowedExtensions; 33 | const defaultCheckExtension = (extension: string) => { 34 | if (allowedExtensions && !allowedExtensions.includes(extension)) { 35 | return `Invalid file type. Please select a file with one of these formats: ${allowedExtensions!.join( 36 | ",", 37 | )}`; 38 | } 39 | return null; 40 | }; 41 | const checkExtension = config?.checkExtension ?? defaultCheckExtension; 42 | 43 | const isFileSizeExceeded = (file: File) => { 44 | return file.size > fileSizeLimit; 45 | }; 46 | 47 | const resetInput = () => { 48 | const fileInput = document.getElementById(inputId) as HTMLInputElement; 49 | fileInput.value = ""; 50 | }; 51 | 52 | const onFileChange = async (e: ChangeEvent) => { 53 | const file = e.target.files?.[0]; 54 | if (!file) return; 55 | 56 | setUploading(true); 57 | await handleUpload(file); 58 | resetInput(); 59 | setUploading(false); 60 | }; 61 | 62 | const handleUpload = async (file: File) => { 63 | const onFileUploadError = onFileError || window.alert; 64 | const fileExtension = file.name.split(".").pop() || ""; 65 | const extensionFileError = checkExtension(fileExtension); 66 | if (extensionFileError) { 67 | return onFileUploadError(extensionFileError); 68 | } 69 | 70 | if (isFileSizeExceeded(file)) { 71 | return onFileUploadError( 72 | `File size exceeded. Limit is ${fileSizeLimit / 1024 / 1024} MB`, 73 | ); 74 | } 75 | 76 | await onFileUpload(file); 77 | }; 78 | 79 | return ( 80 |
81 | 89 | 103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /app/components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Drawer as DrawerPrimitive } from "vaul"; 5 | 6 | import { cn } from "./lib/utils"; 7 | 8 | const Drawer = ({ 9 | shouldScaleBackground = true, 10 | ...props 11 | }: React.ComponentProps) => ( 12 | 16 | ); 17 | Drawer.displayName = "Drawer"; 18 | 19 | const DrawerTrigger = DrawerPrimitive.Trigger; 20 | 21 | const DrawerPortal = DrawerPrimitive.Portal; 22 | 23 | const DrawerClose = DrawerPrimitive.Close; 24 | 25 | const DrawerOverlay = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )); 35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; 36 | 37 | const DrawerContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 42 | 43 | 51 |
52 | {children} 53 | 54 | 55 | )); 56 | DrawerContent.displayName = "DrawerContent"; 57 | 58 | const DrawerHeader = ({ 59 | className, 60 | ...props 61 | }: React.HTMLAttributes) => ( 62 |
66 | ); 67 | DrawerHeader.displayName = "DrawerHeader"; 68 | 69 | const DrawerFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
77 | ); 78 | DrawerFooter.displayName = "DrawerFooter"; 79 | 80 | const DrawerTitle = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )); 93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName; 94 | 95 | const DrawerDescription = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 104 | )); 105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName; 106 | 107 | export { 108 | Drawer, 109 | DrawerClose, 110 | DrawerContent, 111 | DrawerDescription, 112 | DrawerFooter, 113 | DrawerHeader, 114 | DrawerOverlay, 115 | DrawerPortal, 116 | DrawerTitle, 117 | DrawerTrigger, 118 | }; 119 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { initObservability } from "@/app/observability"; 2 | import { JSONValue, Message, StreamData, StreamingTextResponse } from "ai"; 3 | import { ChatMessage, Settings } from "llamaindex"; 4 | import { NextRequest, NextResponse } from "next/server"; 5 | import { createChatEngine } from "./engine/chat"; 6 | import { initSettings } from "./engine/settings"; 7 | import { 8 | convertMessageContent, 9 | retrieveDocumentIds, 10 | } from "./llamaindex/streaming/annotations"; 11 | import { 12 | createCallbackManager, 13 | createStreamTimeout, 14 | } from "./llamaindex/streaming/events"; 15 | import { LlamaIndexStream } from "./llamaindex/streaming/stream"; 16 | 17 | initObservability(); 18 | initSettings(); 19 | 20 | export const runtime = "nodejs"; 21 | export const dynamic = "force-dynamic"; 22 | 23 | export async function POST(request: NextRequest) { 24 | // Init Vercel AI StreamData and timeout 25 | const vercelStreamData = new StreamData(); 26 | const streamTimeout = createStreamTimeout(vercelStreamData); 27 | 28 | try { 29 | const body = await request.json(); 30 | const { messages, data }: { messages: Message[]; data?: any } = body; 31 | const userMessage = messages.pop(); 32 | if (!messages || !userMessage || userMessage.role !== "user") { 33 | return NextResponse.json( 34 | { 35 | error: 36 | "messages are required in the request body and the last message must be from the user", 37 | }, 38 | { status: 400 }, 39 | ); 40 | } 41 | 42 | let annotations = userMessage.annotations; 43 | if (!annotations) { 44 | // the user didn't send any new annotations with the last message 45 | // so use the annotations from the last user message that has annotations 46 | // REASON: GPT4 doesn't consider MessageContentDetail from previous messages, only strings 47 | annotations = messages 48 | .slice() 49 | .reverse() 50 | .find( 51 | (message) => message.role === "user" && message.annotations, 52 | )?.annotations; 53 | } 54 | 55 | // retrieve document Ids from the annotations of all messages (if any) and create chat engine with index 56 | const allAnnotations: JSONValue[] = [...messages, userMessage].flatMap( 57 | (message) => { 58 | return message.annotations ?? []; 59 | }, 60 | ); 61 | const ids = retrieveDocumentIds(allAnnotations); 62 | const chatEngine = await createChatEngine(ids, data); 63 | 64 | // Convert message content from Vercel/AI format to LlamaIndex/OpenAI format 65 | const userMessageContent = convertMessageContent( 66 | userMessage.content, 67 | annotations, 68 | ); 69 | 70 | // Setup callbacks 71 | const callbackManager = createCallbackManager(vercelStreamData); 72 | 73 | // Calling LlamaIndex's ChatEngine to get a streamed response 74 | const response = await Settings.withCallbackManager(callbackManager, () => { 75 | return chatEngine.chat({ 76 | message: userMessageContent, 77 | chatHistory: messages as ChatMessage[], 78 | stream: true, 79 | }); 80 | }); 81 | 82 | // Transform LlamaIndex stream to Vercel/AI format 83 | const stream = LlamaIndexStream( 84 | response, 85 | vercelStreamData, 86 | messages as ChatMessage[], 87 | ); 88 | 89 | // Return a StreamingTextResponse, which can be consumed by the Vercel/AI client 90 | return new StreamingTextResponse(stream, {}, vercelStreamData); 91 | } catch (error) { 92 | console.error("[LlamaIndex]", error); 93 | return NextResponse.json( 94 | { 95 | detail: (error as Error).message, 96 | }, 97 | { 98 | status: 500, 99 | }, 100 | ); 101 | } finally { 102 | clearTimeout(streamTimeout); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /app/api/chat/llamaindex/streaming/annotations.ts: -------------------------------------------------------------------------------- 1 | import { JSONValue } from "ai"; 2 | import { MessageContent, MessageContentDetail } from "llamaindex"; 3 | 4 | export type DocumentFileType = "csv" | "pdf" | "txt" | "docx"; 5 | 6 | export type DocumentFileContent = { 7 | type: "ref" | "text"; 8 | value: string[] | string; 9 | }; 10 | 11 | export type DocumentFile = { 12 | id: string; 13 | filename: string; 14 | filesize: number; 15 | filetype: DocumentFileType; 16 | content: DocumentFileContent; 17 | }; 18 | 19 | type Annotation = { 20 | type: string; 21 | data: object; 22 | }; 23 | 24 | export function retrieveDocumentIds(annotations?: JSONValue[]): string[] { 25 | if (!annotations) return []; 26 | 27 | const ids: string[] = []; 28 | 29 | for (const annotation of annotations) { 30 | const { type, data } = getValidAnnotation(annotation); 31 | if ( 32 | type === "document_file" && 33 | "files" in data && 34 | Array.isArray(data.files) 35 | ) { 36 | const files = data.files as DocumentFile[]; 37 | for (const file of files) { 38 | if (Array.isArray(file.content.value)) { 39 | // it's an array, so it's an array of doc IDs 40 | for (const id of file.content.value) { 41 | ids.push(id); 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | return ids; 49 | } 50 | 51 | export function convertMessageContent( 52 | content: string, 53 | annotations?: JSONValue[], 54 | ): MessageContent { 55 | if (!annotations) return content; 56 | return [ 57 | { 58 | type: "text", 59 | text: content, 60 | }, 61 | ...convertAnnotations(annotations), 62 | ]; 63 | } 64 | 65 | function convertAnnotations(annotations: JSONValue[]): MessageContentDetail[] { 66 | const content: MessageContentDetail[] = []; 67 | annotations.forEach((annotation: JSONValue) => { 68 | const { type, data } = getValidAnnotation(annotation); 69 | // convert image 70 | if (type === "image" && "url" in data && typeof data.url === "string") { 71 | content.push({ 72 | type: "image_url", 73 | image_url: { 74 | url: data.url, 75 | }, 76 | }); 77 | } 78 | // convert the content of files to a text message 79 | if ( 80 | type === "document_file" && 81 | "files" in data && 82 | Array.isArray(data.files) 83 | ) { 84 | // get all CSV files and convert their whole content to one text message 85 | // currently CSV files are the only files where we send the whole content - we don't use an index 86 | const csvFiles: DocumentFile[] = data.files.filter( 87 | (file: DocumentFile) => file.filetype === "csv", 88 | ); 89 | if (csvFiles && csvFiles.length > 0) { 90 | const csvContents = csvFiles.map((file: DocumentFile) => { 91 | const fileContent = Array.isArray(file.content.value) 92 | ? file.content.value.join("\n") 93 | : file.content.value; 94 | return "```csv\n" + fileContent + "\n```"; 95 | }); 96 | const text = 97 | "Use the following CSV content:\n" + csvContents.join("\n\n"); 98 | content.push({ 99 | type: "text", 100 | text, 101 | }); 102 | } 103 | } 104 | }); 105 | 106 | return content; 107 | } 108 | 109 | function getValidAnnotation(annotation: JSONValue): Annotation { 110 | if ( 111 | !( 112 | annotation && 113 | typeof annotation === "object" && 114 | "type" in annotation && 115 | typeof annotation.type === "string" && 116 | "data" in annotation && 117 | annotation.data && 118 | typeof annotation.data === "object" 119 | ) 120 | ) { 121 | throw new Error("Client sent invalid annotation. Missing data and type"); 122 | } 123 | return { type: annotation.type, data: annotation.data }; 124 | } 125 | -------------------------------------------------------------------------------- /app/components/ui/document-preview.tsx: -------------------------------------------------------------------------------- 1 | import { XCircleIcon } from "lucide-react"; 2 | import Image from "next/image"; 3 | import DocxIcon from "../ui/icons/docx.svg"; 4 | import PdfIcon from "../ui/icons/pdf.svg"; 5 | import SheetIcon from "../ui/icons/sheet.svg"; 6 | import TxtIcon from "../ui/icons/txt.svg"; 7 | import { Button } from "./button"; 8 | import { DocumentFile, DocumentFileType } from "./chat"; 9 | import { 10 | Drawer, 11 | DrawerClose, 12 | DrawerContent, 13 | DrawerDescription, 14 | DrawerHeader, 15 | DrawerTitle, 16 | DrawerTrigger, 17 | } from "./drawer"; 18 | import { cn } from "./lib/utils"; 19 | 20 | export interface DocumentPreviewProps { 21 | file: DocumentFile; 22 | onRemove?: () => void; 23 | } 24 | 25 | export function DocumentPreview(props: DocumentPreviewProps) { 26 | const { filename, filesize, content, filetype } = props.file; 27 | 28 | if (content.type === "ref") { 29 | return ( 30 |
31 | 32 |
33 | ); 34 | } 35 | 36 | return ( 37 | 38 | 39 |
40 | 41 |
42 |
43 | 44 | 45 |
46 | {filetype.toUpperCase()} Raw Content 47 | 48 | {filename} ({inKB(filesize)} KB) 49 | 50 |
51 | 52 | 53 | 54 |
55 |
56 | {content.type === "text" && ( 57 |
 58 |               {content.value as string}
 59 |             
60 | )} 61 |
62 |
63 |
64 | ); 65 | } 66 | 67 | export const FileIcon: Record = { 68 | csv: SheetIcon, 69 | pdf: PdfIcon, 70 | docx: DocxIcon, 71 | txt: TxtIcon, 72 | }; 73 | 74 | function PreviewCard(props: DocumentPreviewProps) { 75 | const { onRemove, file } = props; 76 | return ( 77 |
83 |
84 |
85 | Icon 91 |
92 |
93 |
94 | {file.filename} ({inKB(file.filesize)} KB) 95 |
96 |
97 | {file.filetype.toUpperCase()} File 98 |
99 |
100 |
101 | {onRemove && ( 102 |
107 | 111 |
112 | )} 113 |
114 | ); 115 | } 116 | 117 | function inKB(size: number) { 118 | return Math.round((size / 1024) * 10) / 10; 119 | } 120 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-messages.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | import { useEffect, useRef, useState } from "react"; 3 | 4 | import { Button } from "../button"; 5 | import ChatActions from "./chat-actions"; 6 | import ChatMessage from "./chat-message"; 7 | import { ChatHandler } from "./chat.interface"; 8 | import { useClientConfig } from "./hooks/use-config"; 9 | 10 | export default function ChatMessages( 11 | props: Pick< 12 | ChatHandler, 13 | "messages" | "isLoading" | "reload" | "stop" | "append" 14 | >, 15 | ) { 16 | const { backend } = useClientConfig(); 17 | const [starterQuestions, setStarterQuestions] = useState(); 18 | 19 | const scrollableChatContainerRef = useRef(null); 20 | const messageLength = props.messages.length; 21 | const lastMessage = props.messages[messageLength - 1]; 22 | 23 | const scrollToBottom = () => { 24 | if (scrollableChatContainerRef.current) { 25 | scrollableChatContainerRef.current.scrollTop = 26 | scrollableChatContainerRef.current.scrollHeight; 27 | } 28 | }; 29 | 30 | const isLastMessageFromAssistant = 31 | messageLength > 0 && lastMessage?.role !== "user"; 32 | const showReload = 33 | props.reload && !props.isLoading && isLastMessageFromAssistant; 34 | const showStop = props.stop && props.isLoading; 35 | 36 | // `isPending` indicate 37 | // that stream response is not yet received from the server, 38 | // so we show a loading indicator to give a better UX. 39 | const isPending = props.isLoading && !isLastMessageFromAssistant; 40 | 41 | useEffect(() => { 42 | scrollToBottom(); 43 | }, [messageLength, lastMessage]); 44 | 45 | useEffect(() => { 46 | if (!starterQuestions) { 47 | fetch(`${backend}/api/chat/config`) 48 | .then((response) => response.json()) 49 | .then((data) => { 50 | if (data?.starterQuestions) { 51 | setStarterQuestions(data.starterQuestions); 52 | } 53 | }) 54 | .catch((error) => console.error("Error fetching config", error)); 55 | } 56 | }, [starterQuestions, backend]); 57 | 58 | return ( 59 |
63 |
64 | {props.messages.map((m, i) => { 65 | const isLoadingMessage = i === messageLength - 1 && props.isLoading; 66 | return ( 67 | 73 | ); 74 | })} 75 | {isPending && ( 76 |
77 | 78 |
79 | )} 80 |
81 | {(showReload || showStop) && ( 82 |
83 | 89 |
90 | )} 91 | {!messageLength && starterQuestions?.length && props.append && ( 92 |
93 |
94 | {starterQuestions.map((question, i) => ( 95 | 104 | ))} 105 |
106 |
107 | )} 108 |
109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-input.tsx: -------------------------------------------------------------------------------- 1 | import { JSONValue } from "ai"; 2 | import { Button } from "../button"; 3 | import { DocumentPreview } from "../document-preview"; 4 | import FileUploader from "../file-uploader"; 5 | import { Input } from "../input"; 6 | import UploadImagePreview from "../upload-image-preview"; 7 | import { ChatHandler } from "./chat.interface"; 8 | import { useFile } from "./hooks/use-file"; 9 | import { LlamaCloudSelector } from "./widgets/LlamaCloudSelector"; 10 | 11 | const ALLOWED_EXTENSIONS = ["png", "jpg", "jpeg", "csv", "pdf", "txt", "docx"]; 12 | 13 | export default function ChatInput( 14 | props: Pick< 15 | ChatHandler, 16 | | "isLoading" 17 | | "input" 18 | | "onFileUpload" 19 | | "onFileError" 20 | | "handleSubmit" 21 | | "handleInputChange" 22 | | "messages" 23 | | "setInput" 24 | | "append" 25 | > & { 26 | requestParams?: any; 27 | setRequestData?: React.Dispatch; 28 | }, 29 | ) { 30 | const { 31 | imageUrl, 32 | setImageUrl, 33 | uploadFile, 34 | files, 35 | removeDoc, 36 | reset, 37 | getAnnotations, 38 | } = useFile(); 39 | 40 | // default submit function does not handle including annotations in the message 41 | // so we need to use append function to submit new message with annotations 42 | const handleSubmitWithAnnotations = ( 43 | e: React.FormEvent, 44 | annotations: JSONValue[] | undefined, 45 | ) => { 46 | e.preventDefault(); 47 | props.append!({ 48 | content: props.input, 49 | role: "user", 50 | createdAt: new Date(), 51 | annotations, 52 | }); 53 | props.setInput!(""); 54 | }; 55 | 56 | const onSubmit = (e: React.FormEvent) => { 57 | const annotations = getAnnotations(); 58 | if (annotations.length) { 59 | handleSubmitWithAnnotations(e, annotations); 60 | return reset(); 61 | } 62 | props.handleSubmit(e); 63 | }; 64 | 65 | const handleUploadFile = async (file: File) => { 66 | if (imageUrl || files.length > 0) { 67 | alert("You can only upload one file at a time."); 68 | return; 69 | } 70 | try { 71 | await uploadFile(file, props.requestParams); 72 | props.onFileUpload?.(file); 73 | } catch (error: any) { 74 | const onFileUploadError = props.onFileError || window.alert; 75 | onFileUploadError(error.message); 76 | } 77 | }; 78 | 79 | return ( 80 |
84 | {imageUrl && ( 85 | setImageUrl(null)} /> 86 | )} 87 | {files.length > 0 && ( 88 |
89 | {files.map((file) => ( 90 | removeDoc(file)} 94 | /> 95 | ))} 96 |
97 | )} 98 |
99 | 107 | 115 | {process.env.NEXT_PUBLIC_USE_LLAMACLOUD === "true" && 116 | props.setRequestData && ( 117 | 118 | )} 119 | 122 |
123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/codeblock.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Check, Copy, Download } from "lucide-react"; 4 | import { FC, memo } from "react"; 5 | import { Prism, SyntaxHighlighterProps } from "react-syntax-highlighter"; 6 | import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; 7 | 8 | import { Button } from "../../button"; 9 | import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; 10 | 11 | // TODO: Remove this when @type/react-syntax-highlighter is updated 12 | const SyntaxHighlighter = Prism as unknown as FC; 13 | 14 | interface Props { 15 | language: string; 16 | value: string; 17 | } 18 | 19 | interface languageMap { 20 | [key: string]: string | undefined; 21 | } 22 | 23 | export const programmingLanguages: languageMap = { 24 | javascript: ".js", 25 | python: ".py", 26 | java: ".java", 27 | c: ".c", 28 | cpp: ".cpp", 29 | "c++": ".cpp", 30 | "c#": ".cs", 31 | ruby: ".rb", 32 | php: ".php", 33 | swift: ".swift", 34 | "objective-c": ".m", 35 | kotlin: ".kt", 36 | typescript: ".ts", 37 | go: ".go", 38 | perl: ".pl", 39 | rust: ".rs", 40 | scala: ".scala", 41 | haskell: ".hs", 42 | lua: ".lua", 43 | shell: ".sh", 44 | sql: ".sql", 45 | html: ".html", 46 | css: ".css", 47 | // add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component 48 | }; 49 | 50 | export const generateRandomString = (length: number, lowercase = false) => { 51 | const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0 52 | let result = ""; 53 | for (let i = 0; i < length; i++) { 54 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 55 | } 56 | return lowercase ? result.toLowerCase() : result; 57 | }; 58 | 59 | const CodeBlock: FC = memo(({ language, value }) => { 60 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); 61 | 62 | const downloadAsFile = () => { 63 | if (typeof window === "undefined") { 64 | return; 65 | } 66 | const fileExtension = programmingLanguages[language] || ".file"; 67 | const suggestedFileName = `file-${generateRandomString( 68 | 3, 69 | true, 70 | )}${fileExtension}`; 71 | const fileName = window.prompt("Enter file name" || "", suggestedFileName); 72 | 73 | if (!fileName) { 74 | // User pressed cancel on prompt. 75 | return; 76 | } 77 | 78 | const blob = new Blob([value], { type: "text/plain" }); 79 | const url = URL.createObjectURL(blob); 80 | const link = document.createElement("a"); 81 | link.download = fileName; 82 | link.href = url; 83 | link.style.display = "none"; 84 | document.body.appendChild(link); 85 | link.click(); 86 | document.body.removeChild(link); 87 | URL.revokeObjectURL(url); 88 | }; 89 | 90 | const onCopy = () => { 91 | if (isCopied) return; 92 | copyToClipboard(value); 93 | }; 94 | 95 | return ( 96 |
97 |
98 | {language} 99 |
100 | 104 | 112 |
113 |
114 | 132 | {value} 133 | 134 |
135 | ); 136 | }); 137 | CodeBlock.displayName = "CodeBlock"; 138 | 139 | export { CodeBlock }; 140 | -------------------------------------------------------------------------------- /app/components/ui/chat/hooks/use-file.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { JSONValue } from "llamaindex"; 4 | import { useState } from "react"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | import { 7 | DocumentFile, 8 | DocumentFileType, 9 | MessageAnnotation, 10 | MessageAnnotationType, 11 | } from ".."; 12 | import { useClientConfig } from "./use-config"; 13 | 14 | const docMineTypeMap: Record = { 15 | "text/csv": "csv", 16 | "application/pdf": "pdf", 17 | "text/plain": "txt", 18 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document": 19 | "docx", 20 | }; 21 | 22 | export function useFile() { 23 | const { backend } = useClientConfig(); 24 | const [imageUrl, setImageUrl] = useState(null); 25 | const [files, setFiles] = useState([]); 26 | 27 | const docEqual = (a: DocumentFile, b: DocumentFile) => { 28 | if (a.id === b.id) return true; 29 | if (a.filename === b.filename && a.filesize === b.filesize) return true; 30 | return false; 31 | }; 32 | 33 | const addDoc = (file: DocumentFile) => { 34 | const existedFile = files.find((f) => docEqual(f, file)); 35 | if (!existedFile) { 36 | setFiles((prev) => [...prev, file]); 37 | return true; 38 | } 39 | return false; 40 | }; 41 | 42 | const removeDoc = (file: DocumentFile) => { 43 | setFiles((prev) => prev.filter((f) => f.id !== file.id)); 44 | }; 45 | 46 | const reset = () => { 47 | imageUrl && setImageUrl(null); 48 | files.length && setFiles([]); 49 | }; 50 | 51 | const uploadContent = async ( 52 | file: File, 53 | requestParams: any = {}, 54 | ): Promise => { 55 | const base64 = await readContent({ file, asUrl: true }); 56 | const uploadAPI = `${backend}/api/chat/upload`; 57 | const response = await fetch(uploadAPI, { 58 | method: "POST", 59 | headers: { 60 | "Content-Type": "application/json", 61 | }, 62 | body: JSON.stringify({ 63 | ...requestParams, 64 | base64, 65 | filename: file.name, 66 | }), 67 | }); 68 | if (!response.ok) throw new Error("Failed to upload document."); 69 | return await response.json(); 70 | }; 71 | 72 | const getAnnotations = () => { 73 | const annotations: MessageAnnotation[] = []; 74 | if (imageUrl) { 75 | annotations.push({ 76 | type: MessageAnnotationType.IMAGE, 77 | data: { url: imageUrl }, 78 | }); 79 | } 80 | if (files.length > 0) { 81 | annotations.push({ 82 | type: MessageAnnotationType.DOCUMENT_FILE, 83 | data: { files }, 84 | }); 85 | } 86 | return annotations as JSONValue[]; 87 | }; 88 | 89 | const readContent = async (input: { 90 | file: File; 91 | asUrl?: boolean; 92 | }): Promise => { 93 | const { file, asUrl } = input; 94 | const content = await new Promise((resolve, reject) => { 95 | const reader = new FileReader(); 96 | if (asUrl) { 97 | reader.readAsDataURL(file); 98 | } else { 99 | reader.readAsText(file); 100 | } 101 | reader.onload = () => resolve(reader.result as string); 102 | reader.onerror = (error) => reject(error); 103 | }); 104 | return content; 105 | }; 106 | 107 | const uploadFile = async (file: File, requestParams: any = {}) => { 108 | if (file.type.startsWith("image/")) { 109 | const base64 = await readContent({ file, asUrl: true }); 110 | return setImageUrl(base64); 111 | } 112 | 113 | const filetype = docMineTypeMap[file.type]; 114 | if (!filetype) throw new Error("Unsupported document type."); 115 | const newDoc: Omit = { 116 | id: uuidv4(), 117 | filetype, 118 | filename: file.name, 119 | filesize: file.size, 120 | }; 121 | switch (file.type) { 122 | case "text/csv": { 123 | const content = await readContent({ file }); 124 | return addDoc({ 125 | ...newDoc, 126 | content: { 127 | type: "text", 128 | value: content, 129 | }, 130 | }); 131 | } 132 | default: { 133 | const ids = await uploadContent(file, requestParams); 134 | return addDoc({ 135 | ...newDoc, 136 | content: { 137 | type: "ref", 138 | value: ids, 139 | }, 140 | }); 141 | } 142 | } 143 | }; 144 | 145 | return { 146 | imageUrl, 147 | setImageUrl, 148 | files, 149 | removeDoc, 150 | reset, 151 | getAnnotations, 152 | uploadFile, 153 | }; 154 | } 155 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/index.tsx: -------------------------------------------------------------------------------- 1 | import { Check, Copy } from "lucide-react"; 2 | 3 | import { Message } from "ai"; 4 | import { Fragment } from "react"; 5 | import { Button } from "../../button"; 6 | import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; 7 | import { 8 | ChatHandler, 9 | DocumentFileData, 10 | EventData, 11 | ImageData, 12 | MessageAnnotation, 13 | MessageAnnotationType, 14 | SuggestedQuestionsData, 15 | ToolData, 16 | getAnnotationData, 17 | getSourceAnnotationData, 18 | } from "../index"; 19 | import ChatAvatar from "./chat-avatar"; 20 | import { ChatEvents } from "./chat-events"; 21 | import { ChatFiles } from "./chat-files"; 22 | import { ChatImage } from "./chat-image"; 23 | import { ChatSources } from "./chat-sources"; 24 | import { SuggestedQuestions } from "./chat-suggestedQuestions"; 25 | import ChatTools from "./chat-tools"; 26 | import Markdown from "./markdown"; 27 | 28 | type ContentDisplayConfig = { 29 | order: number; 30 | component: JSX.Element | null; 31 | }; 32 | 33 | function ChatMessageContent({ 34 | message, 35 | isLoading, 36 | append, 37 | }: { 38 | message: Message; 39 | isLoading: boolean; 40 | append: Pick["append"]; 41 | }) { 42 | const annotations = message.annotations as MessageAnnotation[] | undefined; 43 | if (!annotations?.length) return ; 44 | 45 | const imageData = getAnnotationData( 46 | annotations, 47 | MessageAnnotationType.IMAGE, 48 | ); 49 | const contentFileData = getAnnotationData( 50 | annotations, 51 | MessageAnnotationType.DOCUMENT_FILE, 52 | ); 53 | const eventData = getAnnotationData( 54 | annotations, 55 | MessageAnnotationType.EVENTS, 56 | ); 57 | 58 | const sourceData = getSourceAnnotationData(annotations); 59 | 60 | const toolData = getAnnotationData( 61 | annotations, 62 | MessageAnnotationType.TOOLS, 63 | ); 64 | const suggestedQuestionsData = getAnnotationData( 65 | annotations, 66 | MessageAnnotationType.SUGGESTED_QUESTIONS, 67 | ); 68 | 69 | const contents: ContentDisplayConfig[] = [ 70 | { 71 | order: 1, 72 | component: imageData[0] ? : null, 73 | }, 74 | { 75 | order: -3, 76 | component: 77 | eventData.length > 0 ? ( 78 | 79 | ) : null, 80 | }, 81 | { 82 | order: 2, 83 | component: contentFileData[0] ? ( 84 | 85 | ) : null, 86 | }, 87 | { 88 | order: -1, 89 | component: toolData[0] ? : null, 90 | }, 91 | { 92 | order: 0, 93 | component: , 94 | }, 95 | { 96 | order: 3, 97 | component: sourceData[0] ? : null, 98 | }, 99 | { 100 | order: 4, 101 | component: suggestedQuestionsData[0] ? ( 102 | 106 | ) : null, 107 | }, 108 | ]; 109 | 110 | return ( 111 |
112 | {contents 113 | .sort((a, b) => a.order - b.order) 114 | .map((content, index) => ( 115 | {content.component} 116 | ))} 117 |
118 | ); 119 | } 120 | 121 | export default function ChatMessage({ 122 | chatMessage, 123 | isLoading, 124 | append, 125 | }: { 126 | chatMessage: Message; 127 | isLoading: boolean; 128 | append: Pick["append"]; 129 | }) { 130 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); 131 | return ( 132 |
133 | 134 |
135 | 140 | 152 |
153 |
154 | ); 155 | } 156 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/markdown.tsx: -------------------------------------------------------------------------------- 1 | import "katex/dist/katex.min.css"; 2 | import { FC, memo } from "react"; 3 | import ReactMarkdown, { Options } from "react-markdown"; 4 | import rehypeKatex from "rehype-katex"; 5 | import remarkGfm from "remark-gfm"; 6 | import remarkMath from "remark-math"; 7 | 8 | import { SourceData } from ".."; 9 | import { SourceNumberButton } from "./chat-sources"; 10 | import { CodeBlock } from "./codeblock"; 11 | 12 | const MemoizedReactMarkdown: FC = memo( 13 | ReactMarkdown, 14 | (prevProps, nextProps) => 15 | prevProps.children === nextProps.children && 16 | prevProps.className === nextProps.className, 17 | ); 18 | 19 | const preprocessLaTeX = (content: string) => { 20 | // Replace block-level LaTeX delimiters \[ \] with $$ $$ 21 | const blockProcessedContent = content.replace( 22 | /\\\[([\s\S]*?)\\\]/g, 23 | (_, equation) => `$$${equation}$$`, 24 | ); 25 | // Replace inline LaTeX delimiters \( \) with $ $ 26 | const inlineProcessedContent = blockProcessedContent.replace( 27 | /\\\[([\s\S]*?)\\\]/g, 28 | (_, equation) => `$${equation}$`, 29 | ); 30 | return inlineProcessedContent; 31 | }; 32 | 33 | const preprocessMedia = (content: string) => { 34 | // Remove `sandbox:` from the beginning of the URL 35 | // to fix OpenAI's models issue appending `sandbox:` to the relative URL 36 | return content.replace(/(sandbox|attachment|snt):/g, ""); 37 | }; 38 | 39 | /** 40 | * Update the citation flag [citation:id]() to the new format [citation:index](url) 41 | */ 42 | const preprocessCitations = (content: string, sources?: SourceData) => { 43 | if (sources) { 44 | const citationRegex = /\[citation:(.+?)\]\(\)/g; 45 | let match; 46 | // Find all the citation references in the content 47 | while ((match = citationRegex.exec(content)) !== null) { 48 | const citationId = match[1]; 49 | // Find the source node with the id equal to the citation-id, also get the index of the source node 50 | const sourceNode = sources.nodes.find((node) => node.id === citationId); 51 | // If the source node is found, replace the citation reference with the new format 52 | if (sourceNode !== undefined) { 53 | content = content.replace( 54 | match[0], 55 | `[citation:${sources.nodes.indexOf(sourceNode)}]()`, 56 | ); 57 | } else { 58 | // If the source node is not found, remove the citation reference 59 | content = content.replace(match[0], ""); 60 | } 61 | } 62 | } 63 | return content; 64 | }; 65 | 66 | const preprocessContent = (content: string, sources?: SourceData) => { 67 | return preprocessCitations( 68 | preprocessMedia(preprocessLaTeX(content)), 69 | sources, 70 | ); 71 | }; 72 | 73 | export default function Markdown({ 74 | content, 75 | sources, 76 | }: { 77 | content: string; 78 | sources?: SourceData; 79 | }) { 80 | const processedContent = preprocessContent(content, sources); 81 | 82 | return ( 83 | {children}

; 90 | }, 91 | code({ node, inline, className, children, ...props }) { 92 | if (children.length) { 93 | if (children[0] == "▍") { 94 | return ( 95 | 96 | ); 97 | } 98 | 99 | children[0] = (children[0] as string).replace("`▍`", "▍"); 100 | } 101 | 102 | const match = /language-(\w+)/.exec(className || ""); 103 | 104 | if (inline) { 105 | return ( 106 | 107 | {children} 108 | 109 | ); 110 | } 111 | 112 | return ( 113 | 119 | ); 120 | }, 121 | a({ href, children }) { 122 | // If a text link starts with 'citation:', then render it as a citation reference 123 | if ( 124 | Array.isArray(children) && 125 | typeof children[0] === "string" && 126 | children[0].startsWith("citation:") 127 | ) { 128 | const index = Number(children[0].replace("citation:", "")); 129 | if (!isNaN(index)) { 130 | return ; 131 | } else { 132 | // citation is not looked up yet, don't render anything 133 | return <>; 134 | } 135 | } 136 | return {children}; 137 | }, 138 | }} 139 | > 140 | {processedContent} 141 |
142 | ); 143 | } 144 | -------------------------------------------------------------------------------- /app/components/ui/chat/widgets/LlamaCloudSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectGroup, 7 | SelectItem, 8 | SelectLabel, 9 | SelectTrigger, 10 | SelectValue, 11 | } from "../../select"; 12 | import { useClientConfig } from "../hooks/use-config"; 13 | 14 | type LLamaCloudPipeline = { 15 | id: string; 16 | name: string; 17 | }; 18 | 19 | type LLamaCloudProject = { 20 | id: string; 21 | organization_id: string; 22 | name: string; 23 | is_default: boolean; 24 | pipelines: Array; 25 | }; 26 | 27 | type PipelineConfig = { 28 | project: string; // project name 29 | pipeline: string; // pipeline name 30 | }; 31 | 32 | type LlamaCloudConfig = { 33 | projects?: LLamaCloudProject[]; 34 | pipeline?: PipelineConfig; 35 | }; 36 | 37 | export interface LlamaCloudSelectorProps { 38 | setRequestData?: React.Dispatch; 39 | onSelect?: (pipeline: PipelineConfig | undefined) => void; 40 | defaultPipeline?: PipelineConfig; 41 | shouldCheckValid?: boolean; 42 | } 43 | 44 | export function LlamaCloudSelector({ 45 | setRequestData, 46 | onSelect, 47 | defaultPipeline, 48 | shouldCheckValid = true, 49 | }: LlamaCloudSelectorProps) { 50 | const { backend } = useClientConfig(); 51 | const [config, setConfig] = useState(); 52 | 53 | const updateRequestParams = useCallback( 54 | (pipeline?: PipelineConfig) => { 55 | if (setRequestData) { 56 | setRequestData({ 57 | llamaCloudPipeline: pipeline, 58 | }); 59 | } else { 60 | onSelect?.(pipeline); 61 | } 62 | }, 63 | [onSelect, setRequestData], 64 | ); 65 | 66 | useEffect(() => { 67 | if (process.env.NEXT_PUBLIC_USE_LLAMACLOUD === "true" && !config) { 68 | fetch(`${backend}/api/chat/config/llamacloud`) 69 | .then((response) => response.json()) 70 | .then((data) => { 71 | const pipeline = defaultPipeline ?? data.pipeline; // defaultPipeline will override pipeline in .env 72 | setConfig({ ...data, pipeline }); 73 | updateRequestParams(pipeline); 74 | }) 75 | .catch((error) => console.error("Error fetching config", error)); 76 | } 77 | }, [backend, config, defaultPipeline, updateRequestParams]); 78 | 79 | const setPipeline = (pipelineConfig?: PipelineConfig) => { 80 | setConfig((prevConfig: any) => ({ 81 | ...prevConfig, 82 | pipeline: pipelineConfig, 83 | })); 84 | updateRequestParams(pipelineConfig); 85 | }; 86 | 87 | const handlePipelineSelect = async (value: string) => { 88 | setPipeline(JSON.parse(value) as PipelineConfig); 89 | }; 90 | 91 | if (!config) { 92 | return ( 93 |
94 | 95 |
96 | ); 97 | } 98 | if (!isValid(config) && shouldCheckValid) { 99 | return ( 100 |

101 | Invalid LlamaCloud configuration. Check console logs. 102 |

103 | ); 104 | } 105 | const { projects, pipeline } = config; 106 | 107 | return ( 108 | 137 | ); 138 | } 139 | 140 | function isValid(config: LlamaCloudConfig): boolean { 141 | const { projects, pipeline } = config; 142 | if (!projects?.length) return false; 143 | if (!pipeline) return false; 144 | const matchedProject = projects.find( 145 | (project: LLamaCloudProject) => project.name === pipeline.project, 146 | ); 147 | if (!matchedProject) { 148 | console.error( 149 | `LlamaCloud project ${pipeline.project} not found. Check LLAMA_CLOUD_PROJECT_NAME variable`, 150 | ); 151 | return false; 152 | } 153 | const pipelineExists = matchedProject.pipelines.some( 154 | (p) => p.name === pipeline.pipeline, 155 | ); 156 | if (!pipelineExists) { 157 | console.error( 158 | `LlamaCloud pipeline ${pipeline.pipeline} not found. Check LLAMA_CLOUD_INDEX_NAME variable`, 159 | ); 160 | return false; 161 | } 162 | return true; 163 | } 164 | -------------------------------------------------------------------------------- /app/components/ui/chat/widgets/WeatherCard.tsx: -------------------------------------------------------------------------------- 1 | export interface WeatherData { 2 | latitude: number; 3 | longitude: number; 4 | generationtime_ms: number; 5 | utc_offset_seconds: number; 6 | timezone: string; 7 | timezone_abbreviation: string; 8 | elevation: number; 9 | current_units: { 10 | time: string; 11 | interval: string; 12 | temperature_2m: string; 13 | weather_code: string; 14 | }; 15 | current: { 16 | time: string; 17 | interval: number; 18 | temperature_2m: number; 19 | weather_code: number; 20 | }; 21 | hourly_units: { 22 | time: string; 23 | temperature_2m: string; 24 | weather_code: string; 25 | }; 26 | hourly: { 27 | time: string[]; 28 | temperature_2m: number[]; 29 | weather_code: number[]; 30 | }; 31 | daily_units: { 32 | time: string; 33 | weather_code: string; 34 | }; 35 | daily: { 36 | time: string[]; 37 | weather_code: number[]; 38 | }; 39 | } 40 | 41 | // Follow WMO Weather interpretation codes (WW) 42 | const weatherCodeDisplayMap: Record< 43 | string, 44 | { 45 | icon: JSX.Element; 46 | status: string; 47 | } 48 | > = { 49 | "0": { 50 | icon: ☀️, 51 | status: "Clear sky", 52 | }, 53 | "1": { 54 | icon: 🌤️, 55 | status: "Mainly clear", 56 | }, 57 | "2": { 58 | icon: ☁️, 59 | status: "Partly cloudy", 60 | }, 61 | "3": { 62 | icon: ☁️, 63 | status: "Overcast", 64 | }, 65 | "45": { 66 | icon: 🌫️, 67 | status: "Fog", 68 | }, 69 | "48": { 70 | icon: 🌫️, 71 | status: "Depositing rime fog", 72 | }, 73 | "51": { 74 | icon: 🌧️, 75 | status: "Drizzle", 76 | }, 77 | "53": { 78 | icon: 🌧️, 79 | status: "Drizzle", 80 | }, 81 | "55": { 82 | icon: 🌧️, 83 | status: "Drizzle", 84 | }, 85 | "56": { 86 | icon: 🌧️, 87 | status: "Freezing Drizzle", 88 | }, 89 | "57": { 90 | icon: 🌧️, 91 | status: "Freezing Drizzle", 92 | }, 93 | "61": { 94 | icon: 🌧️, 95 | status: "Rain", 96 | }, 97 | "63": { 98 | icon: 🌧️, 99 | status: "Rain", 100 | }, 101 | "65": { 102 | icon: 🌧️, 103 | status: "Rain", 104 | }, 105 | "66": { 106 | icon: 🌧️, 107 | status: "Freezing Rain", 108 | }, 109 | "67": { 110 | icon: 🌧️, 111 | status: "Freezing Rain", 112 | }, 113 | "71": { 114 | icon: ❄️, 115 | status: "Snow fall", 116 | }, 117 | "73": { 118 | icon: ❄️, 119 | status: "Snow fall", 120 | }, 121 | "75": { 122 | icon: ❄️, 123 | status: "Snow fall", 124 | }, 125 | "77": { 126 | icon: ❄️, 127 | status: "Snow grains", 128 | }, 129 | "80": { 130 | icon: 🌧️, 131 | status: "Rain showers", 132 | }, 133 | "81": { 134 | icon: 🌧️, 135 | status: "Rain showers", 136 | }, 137 | "82": { 138 | icon: 🌧️, 139 | status: "Rain showers", 140 | }, 141 | "85": { 142 | icon: ❄️, 143 | status: "Snow showers", 144 | }, 145 | "86": { 146 | icon: ❄️, 147 | status: "Snow showers", 148 | }, 149 | "95": { 150 | icon: ⛈️, 151 | status: "Thunderstorm", 152 | }, 153 | "96": { 154 | icon: ⛈️, 155 | status: "Thunderstorm", 156 | }, 157 | "99": { 158 | icon: ⛈️, 159 | status: "Thunderstorm", 160 | }, 161 | }; 162 | 163 | const displayDay = (time: string) => { 164 | return new Date(time).toLocaleDateString("en-US", { 165 | weekday: "long", 166 | }); 167 | }; 168 | 169 | export function WeatherCard({ data }: { data: WeatherData }) { 170 | const currentDayString = new Date(data.current.time).toLocaleDateString( 171 | "en-US", 172 | { 173 | weekday: "long", 174 | month: "long", 175 | day: "numeric", 176 | }, 177 | ); 178 | 179 | return ( 180 |
181 |
182 |
183 |
{currentDayString}
184 |
185 | 186 | {data.current.temperature_2m} {data.current_units.temperature_2m} 187 | 188 | {weatherCodeDisplayMap[data.current.weather_code].icon} 189 |
190 |
191 | 192 | {weatherCodeDisplayMap[data.current.weather_code].status} 193 | 194 |
195 |
196 | {data.daily.time.map((time, index) => { 197 | if (index === 0) return null; // skip the current day 198 | return ( 199 |
200 | {displayDay(time)} 201 |
202 | {weatherCodeDisplayMap[data.daily.weather_code[index]].icon} 203 |
204 | 205 | {weatherCodeDisplayMap[data.daily.weather_code[index]].status} 206 | 207 |
208 | ); 209 | })} 210 |
211 |
212 | ); 213 | } 214 | -------------------------------------------------------------------------------- /app/api/chat/llamaindex/streaming/events.ts: -------------------------------------------------------------------------------- 1 | import { StreamData } from "ai"; 2 | import { 3 | CallbackManager, 4 | LLamaCloudFileService, 5 | Metadata, 6 | MetadataMode, 7 | NodeWithScore, 8 | ToolCall, 9 | ToolOutput, 10 | } from "llamaindex"; 11 | import path from "node:path"; 12 | import { DATA_DIR } from "../../engine/loader"; 13 | import { downloadFile } from "./file"; 14 | 15 | const LLAMA_CLOUD_DOWNLOAD_FOLDER = "output/llamacloud"; 16 | 17 | export function appendSourceData( 18 | data: StreamData, 19 | sourceNodes?: NodeWithScore[], 20 | ) { 21 | if (!sourceNodes?.length) return; 22 | try { 23 | const nodes = sourceNodes.map((node) => ({ 24 | metadata: node.node.metadata, 25 | id: node.node.id_, 26 | score: node.score ?? null, 27 | url: getNodeUrl(node.node.metadata), 28 | text: node.node.getContent(MetadataMode.NONE), 29 | })); 30 | data.appendMessageAnnotation({ 31 | type: "sources", 32 | data: { 33 | nodes, 34 | }, 35 | }); 36 | } catch (error) { 37 | console.error("Error appending source data:", error); 38 | } 39 | } 40 | 41 | export function appendEventData(data: StreamData, title?: string) { 42 | if (!title) return; 43 | data.appendMessageAnnotation({ 44 | type: "events", 45 | data: { 46 | title, 47 | }, 48 | }); 49 | } 50 | 51 | export function appendToolData( 52 | data: StreamData, 53 | toolCall: ToolCall, 54 | toolOutput: ToolOutput, 55 | ) { 56 | data.appendMessageAnnotation({ 57 | type: "tools", 58 | data: { 59 | toolCall: { 60 | id: toolCall.id, 61 | name: toolCall.name, 62 | input: toolCall.input, 63 | }, 64 | toolOutput: { 65 | output: toolOutput.output, 66 | isError: toolOutput.isError, 67 | }, 68 | }, 69 | }); 70 | } 71 | 72 | export function createStreamTimeout(stream: StreamData) { 73 | const timeout = Number(process.env.STREAM_TIMEOUT ?? 1000 * 60 * 5); // default to 5 minutes 74 | const t = setTimeout(() => { 75 | appendEventData(stream, `Stream timed out after ${timeout / 1000} seconds`); 76 | stream.close(); 77 | }, timeout); 78 | return t; 79 | } 80 | 81 | export function createCallbackManager(stream: StreamData) { 82 | const callbackManager = new CallbackManager(); 83 | 84 | callbackManager.on("retrieve-end", (data) => { 85 | const { nodes, query } = data.detail; 86 | appendSourceData(stream, nodes); 87 | appendEventData(stream, `Retrieving context for query: '${query}'`); 88 | appendEventData( 89 | stream, 90 | `Retrieved ${nodes.length} sources to use as context for the query`, 91 | ); 92 | downloadFilesFromNodes(nodes); // don't await to avoid blocking chat streaming 93 | }); 94 | 95 | callbackManager.on("llm-tool-call", (event) => { 96 | const { name, input } = event.detail.toolCall; 97 | const inputString = Object.entries(input) 98 | .map(([key, value]) => `${key}: ${value}`) 99 | .join(", "); 100 | appendEventData( 101 | stream, 102 | `Using tool: '${name}' with inputs: '${inputString}'`, 103 | ); 104 | }); 105 | 106 | callbackManager.on("llm-tool-result", (event) => { 107 | const { toolCall, toolResult } = event.detail; 108 | appendToolData(stream, toolCall, toolResult); 109 | }); 110 | 111 | return callbackManager; 112 | } 113 | 114 | function getNodeUrl(metadata: Metadata) { 115 | if (!process.env.FILESERVER_URL_PREFIX) { 116 | console.warn( 117 | "FILESERVER_URL_PREFIX is not set. File URLs will not be generated.", 118 | ); 119 | } 120 | const fileName = metadata["file_name"]; 121 | if (fileName && process.env.FILESERVER_URL_PREFIX) { 122 | // file_name exists and file server is configured 123 | const pipelineId = metadata["pipeline_id"]; 124 | if (pipelineId) { 125 | const name = toDownloadedName(pipelineId, fileName); 126 | return `${process.env.FILESERVER_URL_PREFIX}/${LLAMA_CLOUD_DOWNLOAD_FOLDER}/${name}`; 127 | } 128 | const isPrivate = metadata["private"] === "true"; 129 | if (isPrivate) { 130 | return `${process.env.FILESERVER_URL_PREFIX}/output/uploaded/${fileName}`; 131 | } 132 | const filePath = metadata["file_path"]; 133 | const dataDir = path.resolve(DATA_DIR); 134 | 135 | if (filePath && dataDir) { 136 | const relativePath = path.relative(dataDir, filePath); 137 | return `${process.env.FILESERVER_URL_PREFIX}/data/${relativePath}`; 138 | } 139 | } 140 | // fallback to URL in metadata (e.g. for websites) 141 | return metadata["URL"]; 142 | } 143 | 144 | async function downloadFilesFromNodes(nodes: NodeWithScore[]) { 145 | try { 146 | const files = nodesToLlamaCloudFiles(nodes); 147 | for (const { pipelineId, fileName, downloadedName } of files) { 148 | const downloadUrl = await LLamaCloudFileService.getFileUrl( 149 | pipelineId, 150 | fileName, 151 | ); 152 | if (downloadUrl) { 153 | await downloadFile( 154 | downloadUrl, 155 | downloadedName, 156 | LLAMA_CLOUD_DOWNLOAD_FOLDER, 157 | ); 158 | } 159 | } 160 | } catch (error) { 161 | console.error("Error downloading files from nodes:", error); 162 | } 163 | } 164 | 165 | function nodesToLlamaCloudFiles(nodes: NodeWithScore[]) { 166 | const files: Array<{ 167 | pipelineId: string; 168 | fileName: string; 169 | downloadedName: string; 170 | }> = []; 171 | for (const node of nodes) { 172 | const pipelineId = node.node.metadata["pipeline_id"]; 173 | const fileName = node.node.metadata["file_name"]; 174 | if (!pipelineId || !fileName) continue; 175 | const isDuplicate = files.some( 176 | (f) => f.pipelineId === pipelineId && f.fileName === fileName, 177 | ); 178 | if (!isDuplicate) { 179 | files.push({ 180 | pipelineId, 181 | fileName, 182 | downloadedName: toDownloadedName(pipelineId, fileName), 183 | }); 184 | } 185 | } 186 | return files; 187 | } 188 | 189 | function toDownloadedName(pipelineId: string, fileName: string) { 190 | return `${pipelineId}$${fileName}`; 191 | } 192 | -------------------------------------------------------------------------------- /app/components/ui/chat/chat-message/chat-sources.tsx: -------------------------------------------------------------------------------- 1 | import { Check, Copy, FileText } from "lucide-react"; 2 | import Image from "next/image"; 3 | import { useMemo } from "react"; 4 | import { Button } from "../../button"; 5 | import { FileIcon } from "../../document-preview"; 6 | import { 7 | HoverCard, 8 | HoverCardContent, 9 | HoverCardTrigger, 10 | } from "../../hover-card"; 11 | import { cn } from "../../lib/utils"; 12 | import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; 13 | import { DocumentFileType, SourceData, SourceNode } from "../index"; 14 | import PdfDialog from "../widgets/PdfDialog"; 15 | 16 | type Document = { 17 | url: string; 18 | sources: SourceNode[]; 19 | }; 20 | 21 | export function ChatSources({ data }: { data: SourceData }) { 22 | const documents: Document[] = useMemo(() => { 23 | // group nodes by document (a document must have a URL) 24 | const nodesByUrl: Record = {}; 25 | data.nodes.forEach((node) => { 26 | const key = node.url; 27 | nodesByUrl[key] ??= []; 28 | nodesByUrl[key].push(node); 29 | }); 30 | 31 | // convert to array of documents 32 | return Object.entries(nodesByUrl).map(([url, sources]) => ({ 33 | url, 34 | sources, 35 | })); 36 | }, [data.nodes]); 37 | 38 | if (documents.length === 0) return null; 39 | 40 | return ( 41 |
42 |
Sources:
43 |
44 | {documents.map((document) => { 45 | return ; 46 | })} 47 |
48 |
49 | ); 50 | } 51 | 52 | export function SourceInfo({ 53 | node, 54 | index, 55 | }: { 56 | node?: SourceNode; 57 | index: number; 58 | }) { 59 | if (!node) return ; 60 | return ( 61 | 62 | { 65 | e.preventDefault(); 66 | e.stopPropagation(); 67 | }} 68 | > 69 | 73 | 74 | 75 | 76 | 77 | 78 | ); 79 | } 80 | 81 | export function SourceNumberButton({ 82 | index, 83 | className, 84 | }: { 85 | index: number; 86 | className?: string; 87 | }) { 88 | return ( 89 | 95 | {index + 1} 96 | 97 | ); 98 | } 99 | 100 | function DocumentInfo({ document }: { document: Document }) { 101 | if (!document.sources.length) return null; 102 | const { url, sources } = document; 103 | const fileName = sources[0].metadata.file_name as string | undefined; 104 | const fileExt = fileName?.split(".").pop(); 105 | const fileImage = fileExt ? FileIcon[fileExt as DocumentFileType] : null; 106 | 107 | const DocumentDetail = ( 108 |
112 |

119 | {fileName ?? url} 120 |

121 |
122 |
123 | {sources.map((node: SourceNode, index: number) => { 124 | return ( 125 |
126 | 127 |
128 | ); 129 | })} 130 |
131 | {fileImage ? ( 132 |
133 | Icon 139 |
140 | ) : ( 141 | 142 | )} 143 |
144 |
145 | ); 146 | 147 | if (url.endsWith(".pdf")) { 148 | // open internal pdf dialog for pdf files when click document card 149 | return ; 150 | } 151 | // open external link when click document card for other file types 152 | return
window.open(url, "_blank")}>{DocumentDetail}
; 153 | } 154 | 155 | function NodeInfo({ nodeInfo }: { nodeInfo: SourceNode }) { 156 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 }); 157 | 158 | const pageNumber = 159 | // XXX: page_label is used in Python, but page_number is used by Typescript 160 | (nodeInfo.metadata?.page_number as number) ?? 161 | (nodeInfo.metadata?.page_label as number) ?? 162 | null; 163 | 164 | return ( 165 |
166 |
167 | 168 | {pageNumber ? `On page ${pageNumber}:` : "Node content:"} 169 | 170 | {nodeInfo.text && ( 171 | 186 | )} 187 |
188 | 189 | {nodeInfo.text && ( 190 |
191 |           “{nodeInfo.text}”
192 |         
193 | )} 194 |
195 | ); 196 | } 197 | -------------------------------------------------------------------------------- /app/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as SelectPrimitive from "@radix-ui/react-select"; 4 | import { Check, ChevronDown, ChevronUp } from "lucide-react"; 5 | import * as React from "react"; 6 | import { cn } from "./lib/utils"; 7 | 8 | const Select = SelectPrimitive.Root; 9 | 10 | const SelectGroup = SelectPrimitive.Group; 11 | 12 | const SelectValue = SelectPrimitive.Value; 13 | 14 | const SelectTrigger = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, children, ...props }, ref) => ( 18 | span]:line-clamp-1", 22 | className, 23 | )} 24 | {...props} 25 | > 26 | {children} 27 | 28 | 29 | 30 | 31 | )); 32 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 33 | 34 | const SelectScrollUpButton = React.forwardRef< 35 | React.ElementRef, 36 | React.ComponentPropsWithoutRef 37 | >(({ className, ...props }, ref) => ( 38 | 46 | 47 | 48 | )); 49 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; 50 | 51 | const SelectScrollDownButton = React.forwardRef< 52 | React.ElementRef, 53 | React.ComponentPropsWithoutRef 54 | >(({ className, ...props }, ref) => ( 55 | 63 | 64 | 65 | )); 66 | SelectScrollDownButton.displayName = 67 | SelectPrimitive.ScrollDownButton.displayName; 68 | 69 | const SelectContent = React.forwardRef< 70 | React.ElementRef, 71 | React.ComponentPropsWithoutRef 72 | >(({ className, children, position = "popper", ...props }, ref) => ( 73 | 74 | 85 | 86 | 93 | {children} 94 | 95 | 96 | 97 | 98 | )); 99 | SelectContent.displayName = SelectPrimitive.Content.displayName; 100 | 101 | const SelectLabel = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )); 111 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 112 | 113 | const SelectItem = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, children, ...props }, ref) => ( 117 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | {children} 132 | 133 | )); 134 | SelectItem.displayName = SelectPrimitive.Item.displayName; 135 | 136 | const SelectSeparator = React.forwardRef< 137 | React.ElementRef, 138 | React.ComponentPropsWithoutRef 139 | >(({ className, ...props }, ref) => ( 140 | 145 | )); 146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName; 147 | 148 | export { 149 | Select, 150 | SelectContent, 151 | SelectGroup, 152 | SelectItem, 153 | SelectLabel, 154 | SelectScrollDownButton, 155 | SelectScrollUpButton, 156 | SelectSeparator, 157 | SelectTrigger, 158 | SelectValue, 159 | }; 160 | -------------------------------------------------------------------------------- /app/api/chat/engine/settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ALL_AVAILABLE_MISTRAL_MODELS, 3 | Anthropic, 4 | GEMINI_EMBEDDING_MODEL, 5 | GEMINI_MODEL, 6 | Gemini, 7 | GeminiEmbedding, 8 | Groq, 9 | MistralAI, 10 | MistralAIEmbedding, 11 | MistralAIEmbeddingModelType, 12 | OpenAI, 13 | OpenAIEmbedding, 14 | Settings, 15 | SentenceSplitter, 16 | } from "llamaindex"; 17 | import { HuggingFaceEmbedding } from "llamaindex/embeddings/HuggingFaceEmbedding"; 18 | import { OllamaEmbedding } from "llamaindex/embeddings/OllamaEmbedding"; 19 | import { ALL_AVAILABLE_ANTHROPIC_MODELS } from "llamaindex/llm/anthropic"; 20 | import { Ollama } from "llamaindex/llm/ollama"; 21 | 22 | class CustomSentenceSplitter extends SentenceSplitter { 23 | constructor(params?: any) { 24 | super(params); 25 | } 26 | 27 | // Overriding the _splitText method with type annotations 28 | _splitText(text: string): string[] { 29 | if (text === "") return [text]; 30 | 31 | 32 | const callbackManager = Settings.callbackManager; 33 | callbackManager.dispatchEvent("chunking-start", { 34 | text: [text] 35 | }); 36 | 37 | const splits = text.split("\n\n------split------\n\n") 38 | 39 | console.log("splits", splits) 40 | 41 | return splits; 42 | } 43 | } 44 | 45 | export const initSettings = async () => { 46 | // HINT: you can delete the initialization code for unused model providers 47 | console.log(`Using '${process.env.MODEL_PROVIDER}' model provider`); 48 | 49 | if (!process.env.MODEL || !process.env.EMBEDDING_MODEL) { 50 | throw new Error("'MODEL' and 'EMBEDDING_MODEL' env variables must be set."); 51 | } 52 | 53 | switch (process.env.MODEL_PROVIDER) { 54 | case "ollama": 55 | initOllama(); 56 | break; 57 | case "groq": 58 | initGroq(); 59 | break; 60 | case "anthropic": 61 | initAnthropic(); 62 | break; 63 | case "gemini": 64 | initGemini(); 65 | break; 66 | case "mistral": 67 | initMistralAI(); 68 | break; 69 | case "azure-openai": 70 | initAzureOpenAI(); 71 | break; 72 | default: 73 | initOpenAI(); 74 | break; 75 | } 76 | 77 | const nodeParser = new CustomSentenceSplitter(); 78 | Settings.nodeParser = nodeParser 79 | }; 80 | 81 | function initOpenAI() { 82 | Settings.llm = new OpenAI({ 83 | model: process.env.MODEL ?? "gpt-4o-mini", 84 | maxTokens: process.env.LLM_MAX_TOKENS 85 | ? Number(process.env.LLM_MAX_TOKENS) 86 | : undefined, 87 | }); 88 | Settings.embedModel = new OpenAIEmbedding({ 89 | model: process.env.EMBEDDING_MODEL, 90 | dimensions: process.env.EMBEDDING_DIM 91 | ? parseInt(process.env.EMBEDDING_DIM) 92 | : undefined, 93 | }); 94 | } 95 | 96 | function initAzureOpenAI() { 97 | // Map Azure OpenAI model names to OpenAI model names (only for TS) 98 | const AZURE_OPENAI_MODEL_MAP: Record = { 99 | "gpt-35-turbo": "gpt-3.5-turbo", 100 | "gpt-35-turbo-16k": "gpt-3.5-turbo-16k", 101 | "gpt-4o": "gpt-4o", 102 | "gpt-4": "gpt-4", 103 | "gpt-4-32k": "gpt-4-32k", 104 | "gpt-4-turbo": "gpt-4-turbo", 105 | "gpt-4-turbo-2024-04-09": "gpt-4-turbo", 106 | "gpt-4-vision-preview": "gpt-4-vision-preview", 107 | "gpt-4-1106-preview": "gpt-4-1106-preview", 108 | "gpt-4o-2024-05-13": "gpt-4o-2024-05-13", 109 | }; 110 | 111 | const azureConfig = { 112 | apiKey: process.env.AZURE_OPENAI_KEY, 113 | endpoint: process.env.AZURE_OPENAI_ENDPOINT, 114 | apiVersion: 115 | process.env.AZURE_OPENAI_API_VERSION || process.env.OPENAI_API_VERSION, 116 | }; 117 | 118 | Settings.llm = new OpenAI({ 119 | model: 120 | AZURE_OPENAI_MODEL_MAP[process.env.MODEL ?? "gpt-35-turbo"] ?? 121 | "gpt-3.5-turbo", 122 | maxTokens: process.env.LLM_MAX_TOKENS 123 | ? Number(process.env.LLM_MAX_TOKENS) 124 | : undefined, 125 | azure: { 126 | ...azureConfig, 127 | deployment: process.env.AZURE_OPENAI_LLM_DEPLOYMENT, 128 | }, 129 | }); 130 | 131 | Settings.embedModel = new OpenAIEmbedding({ 132 | model: process.env.EMBEDDING_MODEL, 133 | dimensions: process.env.EMBEDDING_DIM 134 | ? parseInt(process.env.EMBEDDING_DIM) 135 | : undefined, 136 | azure: { 137 | ...azureConfig, 138 | deployment: process.env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT, 139 | }, 140 | }); 141 | } 142 | 143 | function initOllama() { 144 | const config = { 145 | host: process.env.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434", 146 | }; 147 | Settings.llm = new Ollama({ 148 | model: process.env.MODEL ?? "", 149 | config, 150 | }); 151 | Settings.embedModel = new OllamaEmbedding({ 152 | model: process.env.EMBEDDING_MODEL ?? "", 153 | config, 154 | }); 155 | } 156 | 157 | function initGroq() { 158 | const embedModelMap: Record = { 159 | "all-MiniLM-L6-v2": "Xenova/all-MiniLM-L6-v2", 160 | "all-mpnet-base-v2": "Xenova/all-mpnet-base-v2", 161 | }; 162 | 163 | const modelMap: Record = { 164 | "llama3-8b": "llama3-8b-8192", 165 | "llama3-70b": "llama3-70b-8192", 166 | "mixtral-8x7b": "mixtral-8x7b-32768", 167 | }; 168 | 169 | Settings.llm = new Groq({ 170 | model: modelMap[process.env.MODEL!], 171 | }); 172 | 173 | Settings.embedModel = new HuggingFaceEmbedding({ 174 | modelType: embedModelMap[process.env.EMBEDDING_MODEL!], 175 | }); 176 | } 177 | 178 | function initAnthropic() { 179 | const embedModelMap: Record = { 180 | "all-MiniLM-L6-v2": "Xenova/all-MiniLM-L6-v2", 181 | "all-mpnet-base-v2": "Xenova/all-mpnet-base-v2", 182 | }; 183 | Settings.llm = new Anthropic({ 184 | model: process.env.MODEL as keyof typeof ALL_AVAILABLE_ANTHROPIC_MODELS, 185 | }); 186 | Settings.embedModel = new HuggingFaceEmbedding({ 187 | modelType: embedModelMap[process.env.EMBEDDING_MODEL!], 188 | }); 189 | } 190 | 191 | function initGemini() { 192 | Settings.llm = new Gemini({ 193 | model: process.env.MODEL as GEMINI_MODEL, 194 | }); 195 | Settings.embedModel = new GeminiEmbedding({ 196 | model: process.env.EMBEDDING_MODEL as GEMINI_EMBEDDING_MODEL, 197 | }); 198 | } 199 | 200 | function initMistralAI() { 201 | Settings.llm = new MistralAI({ 202 | model: process.env.MODEL as keyof typeof ALL_AVAILABLE_MISTRAL_MODELS, 203 | }); 204 | Settings.embedModel = new MistralAIEmbedding({ 205 | model: process.env.EMBEDDING_MODEL as MistralAIEmbeddingModelType, 206 | }); 207 | } 208 | -------------------------------------------------------------------------------- /app/components/ui/icons/sheet.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | Sheets-icon 6 | Created with Sketch. 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | --------------------------------------------------------------------------------