├── 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 |
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 |
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 |
16 |
17 | Stop generating
18 |
19 | )}
20 | {props.showReload && (
21 |
22 |
23 | Regenerate
24 |
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 |
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 |
33 | {isLoading ? : null}
34 | {buttonLabel}
35 | {EventIcon}
36 |
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 |
50 |
51 | Close
52 |
53 |
54 |
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 |
97 | {uploading ? (
98 |
99 | ) : (
100 |
101 | )}
102 |
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 |
33 | );
34 | }
35 |
36 | return (
37 |
38 |
39 |
42 |
43 |
44 |
45 |
46 | {filetype.toUpperCase()} Raw Content
47 |
48 | {filename} ({inKB(filesize)} KB)
49 |
50 |
51 |
52 | Close
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 |
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 |
99 | props.append!({ role: "user", content: question })
100 | }
101 | >
102 | {question}
103 |
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 |
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 |
101 |
102 | Download
103 |
104 |
105 | {isCopied ? (
106 |
107 | ) : (
108 |
109 | )}
110 | Copy code
111 |
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 | copyToClipboard(chatMessage.content)}
142 | size="icon"
143 | variant="ghost"
144 | className="h-8 w-8 opacity-0 group-hover:opacity-100"
145 | >
146 | {isCopied ? (
147 |
148 | ) : (
149 |
150 | )}
151 |
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 |
112 |
113 |
114 |
115 |
116 | {projects!.map((project: LLamaCloudProject) => (
117 |
118 |
119 | Project: {project.name}
120 |
121 | {project.pipelines.map((pipeline) => (
122 |
130 | {pipeline.name}
131 |
132 | ))}
133 |
134 | ))}
135 |
136 |
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 |
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 | {
173 | e.stopPropagation();
174 | copyToClipboard(nodeInfo.text);
175 | }}
176 | size="icon"
177 | variant="ghost"
178 | className="h-12 w-12 shrink-0"
179 | >
180 | {isCopied ? (
181 |
182 | ) : (
183 |
184 | )}
185 |
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 |
--------------------------------------------------------------------------------