Compare commits

...

27 Commits

Author SHA1 Message Date
Marcus Schiesser d22310d11c Merge branch 'main' into feat/upload-pdf 2024-07-03 11:18:07 +02:00
Marcus Schiesser 0a195f82bf feat: use local index 2024-06-27 17:07:26 +02:00
Thuc Pham 948b1b61a9 fix: lint 2024-06-27 10:57:43 +07:00
Thuc Pham d07ffe95e5 feat: add embed api for express 2024-06-27 10:52:16 +07:00
Thuc Pham 4b66d29716 move embeddng to chat folder 2024-06-27 10:38:27 +07:00
Thuc Pham afb54058dc feat: support uploading docx, pdf, txt 2024-06-27 10:23:50 +07:00
Marcus Schiesser cca49c5462 fix: next.config 2024-06-26 17:01:11 +02:00
Marcus Schiesser 28696d5415 refactor: use PDFReader and TextNode 2024-06-26 16:55:49 +02:00
Thuc Pham ee8cb008d8 refactor: move upload logic to useFile 2024-06-26 17:26:27 +07:00
Thuc Pham 689ad9b5b1 fix: lint 2024-06-26 16:55:24 +07:00
Thuc Pham 3ca2f65cbe refactor: document preview component 2024-06-26 16:54:28 +07:00
Thuc Pham 7ff484398f refactor: rename annotation type 2024-06-26 16:49:27 +07:00
Thuc Pham 9c8192dc63 refactor: rename ContentFile to DocumentFile 2024-06-26 16:47:49 +07:00
Thuc Pham fa991f4b52 Create late-weeks-sneeze.md 2024-06-26 16:44:33 +07:00
Thuc Pham 4ba4c64cf2 fix: lint 2024-06-26 16:31:33 +07:00
Thuc Pham 04a4dc9454 return backend for useClientConfig only 2024-06-26 16:30:01 +07:00
Thuc Pham 2d19560006 fix: body collapsed when open dialog 2024-06-26 16:19:23 +07:00
Thuc Pham f707c6fd18 use svg image for pdf, docx, txt 2024-06-26 16:19:04 +07:00
Thuc Pham f4520276d7 use pipeline transformation & upgrade llamaindex latest 2024-06-26 15:55:21 +07:00
Thuc Pham 035e96eefd fix: lint 2024-06-26 15:41:08 +07:00
Thuc Pham d458783c8a Merge branch 'main' into feat/upload-pdf 2024-06-26 15:39:36 +07:00
Thuc Pham d73ee43ac6 refactor: use content file type for all text files 2024-06-26 15:34:13 +07:00
Thuc Pham 312b6d3b20 refactor: file content preview component 2024-06-26 14:23:15 +07:00
Thuc Pham 97af04e8f2 refactor: move embed to chat api folder 2024-06-26 09:20:28 +07:00
Thuc Pham 5a06cf9685 fix: lint 2024-06-26 09:17:52 +07:00
Thuc Pham 0d140f4105 Merge branch 'main' into feat/upload-pdf 2024-06-24 15:35:34 +07:00
Thuc Pham 8db540598d feat: upload pdf and send content to LLM 2024-06-24 15:33:12 +07:00
31 changed files with 716 additions and 346 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"create-llama": patch
---
Support upload document files: pdf, docx, txt
@@ -1,15 +1,20 @@
import { BaseToolWithCall, OpenAIAgent, QueryEngineTool } from "llamaindex";
import {
BaseToolWithCall,
OpenAIAgent,
QueryEngineTool,
TextNode,
} from "llamaindex";
import fs from "node:fs/promises";
import path from "node:path";
import { getDataSource } from "./index";
import { createTools } from "./tools";
export async function createChatEngine() {
export async function createChatEngine(nodes?: TextNode[]) {
const tools: BaseToolWithCall[] = [];
// Add a query engine tool if we have a data source
// Delete this code if you don't have a data source
const index = await getDataSource();
const index = await getDataSource(nodes);
if (index) {
tools.push(
new QueryEngineTool({
@@ -3,22 +3,20 @@
import { useEffect, useMemo, useState } from "react";
export interface ChatConfig {
chatAPI?: string;
backend?: string;
starterQuestions?: string[];
}
export function useClientConfig() {
const API_ROUTE = "/api/chat/config";
export function useClientConfig(): ChatConfig {
const chatAPI = process.env.NEXT_PUBLIC_CHAT_API;
const [config, setConfig] = useState<ChatConfig>({
chatAPI,
});
const [config, setConfig] = useState<ChatConfig>();
const configAPI = useMemo(() => {
const backendOrigin = chatAPI ? new URL(chatAPI).origin : "";
return `${backendOrigin}${API_ROUTE}`;
const backendOrigin = useMemo(() => {
return chatAPI ? new URL(chatAPI).origin : "";
}, [chatAPI]);
const configAPI = `${backendOrigin}/api/chat/config`;
useEffect(() => {
fetch(configAPI)
.then((response) => response.json())
@@ -26,5 +24,8 @@ export function useClientConfig() {
.catch((error) => console.error("Error fetching config", error));
}, [chatAPI, configAPI]);
return config;
return {
backend: backendOrigin,
starterQuestions: config?.starterQuestions,
};
}
@@ -1,8 +1,21 @@
import { SimpleDocumentStore, VectorStoreIndex } from "llamaindex";
import { SimpleDocumentStore, TextNode, VectorStoreIndex } from "llamaindex";
import { storageContextFromDefaults } from "llamaindex/storage/StorageContext";
import { STORAGE_CACHE_DIR } from "./shared";
export async function getDataSource() {
export async function getDataSource(nodes?: TextNode[]) {
if (nodes && nodes.length > 0) {
// the user send some local nodes, we create an index using them and
// prefer that index over the server side index.
// TODO: merge indexes, currently we prefer nodes that are send by the user
return await VectorStoreIndex.init({
nodes,
});
} else {
return await getServerDataSource();
}
}
async function getServerDataSource() {
const storageContext = await storageContextFromDefaults({
persistDir: `${STORAGE_CACHE_DIR}`,
});
@@ -0,0 +1,12 @@
import { Request, Response } from "express";
import { readAndSplitDocument } from "./embeddings";
export const chatEmbed = async (req: Request, res: Response) => {
const { base64 }: { base64: string } = req.body;
if (!base64) {
return res.status(400).json({
error: "base64 is required in the request body",
});
}
return res.status(200).json(await readAndSplitDocument(base64));
};
@@ -0,0 +1,58 @@
import {
Document,
IngestionPipeline,
MetadataMode,
Settings,
SimpleNodeParser,
TextNode,
} from "llamaindex";
import { DocxReader } from "llamaindex/readers/DocxReader";
import { PDFReader } from "llamaindex/readers/PDFReader";
import { TextFileReader } from "llamaindex/readers/TextFileReader";
export async function readAndSplitDocument(
raw: string,
): Promise<Pick<TextNode, "text" | "embedding">[]> {
const [header, content] = raw.split(",");
const mimeType = header.replace("data:", "").replace(";base64", "");
const fileBuffer = Buffer.from(content, "base64");
const documents = await loadDocuments(fileBuffer, mimeType);
return await runPipeline(documents);
}
async function runPipeline(
documents: Document[],
): Promise<Pick<TextNode, "text" | "embedding">[]> {
const pipeline = new IngestionPipeline({
transformations: [
new SimpleNodeParser({
chunkSize: Settings.chunkSize,
chunkOverlap: Settings.chunkOverlap,
}),
Settings.embedModel,
],
});
const nodes = await pipeline.run({ documents });
// remove metadata from text nodes to reduce data send over the wire
return nodes.map((node) => ({
text: node.getContent(MetadataMode.NONE),
embedding: node.embedding,
}));
}
async function loadDocuments(fileBuffer: Buffer, mimeType: string) {
console.log(`Processing uploaded document of type: ${mimeType}`);
switch (mimeType) {
case "application/pdf":
const pdfReader = new PDFReader();
return await pdfReader.loadDataAsContent(new Uint8Array(fileBuffer));
case "text/plain":
const textReader = new TextFileReader();
return await textReader.loadDataAsContent(fileBuffer);
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
const docxReader = new DocxReader();
return await docxReader.loadDataAsContent(fileBuffer);
default:
throw new Error(`Unsupported document type: ${mimeType}`);
}
}
@@ -11,8 +11,7 @@ import {
MessageContent,
MessageContentDetail,
} from "llamaindex";
import { CsvFile } from "./stream-helper";
import { DocumentFile } from "./stream-helper";
export const convertMessageContent = (
content: string,
@@ -60,17 +59,15 @@ const convertAnnotations = (
},
});
}
// convert CSV files to text
if (type === "csv" && "csvFiles" in data && Array.isArray(data.csvFiles)) {
const rawContents = data.csvFiles.map((csv) => {
return "```csv\n" + (csv as CsvFile).content + "\n```";
});
const csvContent =
"Use data from following CSV raw contents:\n" +
rawContents.join("\n\n");
// convert files to text
if (
type === "document_file" &&
"files" in data &&
Array.isArray(data.files)
) {
content.push({
type: "text",
text: csvContent,
text: getDocFileContext(data.files as DocumentFile[]),
});
}
});
@@ -78,6 +75,19 @@ const convertAnnotations = (
return content;
};
// TODO: we must get here the relevant context based on the embeddings by comparing
// it with the user's query
function getDocFileContext(files: DocumentFile[]): string {
const rawContents = files.map((file) => {
const { content } = file;
const context = Array.isArray(content)
? content.map((node) => node.text).join("\n")
: content;
return "```" + `${context}\n` + "```";
});
return `Use the following context:\n` + rawContents.join("\n\n");
}
function createParser(res: AsyncIterable<EngineResponse>, data: StreamData) {
const it = res[Symbol.asyncIterator]();
const trimStartOfStream = trimStartOfStreamHelper();
@@ -3,6 +3,7 @@ import {
CallbackManager,
Metadata,
NodeWithScore,
TextNode,
ToolCall,
ToolOutput,
} from "llamaindex";
@@ -113,9 +114,12 @@ export function createCallbackManager(stream: StreamData) {
return callbackManager;
}
export type CsvFile = {
content: string;
export type DocumentFileType = "csv" | "pdf" | "txt" | "docx";
export type DocumentFile = {
id: string;
filename: string;
filesize: number;
id: string;
filetype: DocumentFileType;
content: string | TextNode[];
};
@@ -1,5 +1,6 @@
import express, { Router } from "express";
import { chatConfig } from "../controllers/chat-config.controller";
import { chatEmbed } from "../controllers/chat-embed.controller";
import { chatRequest } from "../controllers/chat-request.controller";
import { chat } from "../controllers/chat.controller";
import { initSettings } from "../controllers/engine/settings";
@@ -10,5 +11,6 @@ initSettings();
llmRouter.route("/").post(chat);
llmRouter.route("/request").post(chatRequest);
llmRouter.route("/config").get(chatConfig);
llmRouter.route("/embed").post(chatEmbed);
export default llmRouter;
@@ -0,0 +1,60 @@
import {
Document,
IngestionPipeline,
MetadataMode,
Settings,
SimpleNodeParser,
TextNode,
} from "llamaindex";
import { DocxReader } from "llamaindex/readers/DocxReader";
import { PDFReader } from "llamaindex/readers/PDFReader";
import { TextFileReader } from "llamaindex/readers/TextFileReader";
type SimpleTextNode = Pick<TextNode, "text" | "embedding" | "metadata">;
export async function readAndSplitDocument(
raw: string,
): Promise<SimpleTextNode[]> {
const [header, content] = raw.split(",");
const mimeType = header.replace("data:", "").replace(";base64", "");
const fileBuffer = Buffer.from(content, "base64");
const documents = await loadDocuments(fileBuffer, mimeType);
return await runPipeline(documents);
}
async function runPipeline(documents: Document[]): Promise<SimpleTextNode[]> {
const pipeline = new IngestionPipeline({
transformations: [
new SimpleNodeParser({
chunkSize: Settings.chunkSize,
chunkOverlap: Settings.chunkOverlap,
}),
Settings.embedModel,
],
});
const nodes = await pipeline.run({ documents });
// remove text nodes to reduce data send over the wire
return nodes.map((node: TextNode) => ({
text: node.getContent(MetadataMode.NONE),
embedding: node.embedding,
// TODO: to be able to view the source document, we need to store its URL in the metadata
metadata: node.metadata,
}));
}
async function loadDocuments(fileBuffer: Buffer, mimeType: string) {
console.log(`Processing uploaded document of type: ${mimeType}`);
switch (mimeType) {
case "application/pdf":
const pdfReader = new PDFReader();
return await pdfReader.loadDataAsContent(new Uint8Array(fileBuffer));
case "text/plain":
const textReader = new TextFileReader();
return await textReader.loadDataAsContent(fileBuffer);
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
const docxReader = new DocxReader();
return await docxReader.loadDataAsContent(fileBuffer);
default:
throw new Error(`Unsupported document type: ${mimeType}`);
}
}
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { initSettings } from "../engine/settings";
import { readAndSplitDocument } from "./embeddings";
initSettings();
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const { base64 }: { base64: string } = await request.json();
if (!base64) {
return NextResponse.json(
{ error: "base64 is required in the request body" },
{ status: 400 },
);
}
return NextResponse.json(await readAndSplitDocument(base64));
} catch (error) {
console.error("[Embed API]", error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 },
);
}
}
@@ -1,111 +0,0 @@
import {
JSONValue,
StreamData,
createCallbacksTransformer,
createStreamDataTransformer,
trimStartOfStreamHelper,
type AIStreamCallbacksAndOptions,
} from "ai";
import {
EngineResponse,
MessageContent,
MessageContentDetail,
} from "llamaindex";
import { CsvFile } from "./stream-helper";
export const convertMessageContent = (
content: string,
annotations?: JSONValue[],
): MessageContent => {
if (!annotations) return content;
return [
{
type: "text",
text: content,
},
...convertAnnotations(annotations),
];
};
const convertAnnotations = (
annotations: JSONValue[],
): MessageContentDetail[] => {
const content: MessageContentDetail[] = [];
annotations.forEach((annotation: JSONValue) => {
// first skip invalid annotation
if (
!(
annotation &&
typeof annotation === "object" &&
"type" in annotation &&
"data" in annotation &&
annotation.data &&
typeof annotation.data === "object"
)
) {
console.log(
"Client sent invalid annotation. Missing data and type",
annotation,
);
return;
}
const { type, data } = annotation;
// convert image
if (type === "image" && "url" in data && typeof data.url === "string") {
content.push({
type: "image_url",
image_url: {
url: data.url,
},
});
}
// convert CSV files to text
if (type === "csv" && "csvFiles" in data && Array.isArray(data.csvFiles)) {
const rawContents = data.csvFiles.map((csv) => {
return "```csv\n" + (csv as CsvFile).content + "\n```";
});
const csvContent =
"Use data from following CSV raw contents:\n" +
rawContents.join("\n\n");
content.push({
type: "text",
text: csvContent,
});
}
});
return content;
};
function createParser(res: AsyncIterable<EngineResponse>, data: StreamData) {
const it = res[Symbol.asyncIterator]();
const trimStartOfStream = trimStartOfStreamHelper();
return new ReadableStream<string>({
async pull(controller): Promise<void> {
const { value, done } = await it.next();
if (done) {
controller.close();
data.close();
return;
}
const text = trimStartOfStream(value.delta ?? "");
if (text) {
controller.enqueue(text);
}
},
});
}
export function LlamaIndexStream(
response: AsyncIterable<EngineResponse>,
data: StreamData,
opts?: {
callbacks?: AIStreamCallbacksAndOptions;
},
): ReadableStream<Uint8Array> {
return createParser(response, data)
.pipeThrough(createCallbacksTransformer(opts?.callbacks))
.pipeThrough(createStreamDataTransformer());
}
@@ -0,0 +1,123 @@
import { JSONValue } from "ai";
import { MessageContent, MessageContentDetail, TextNode } from "llamaindex";
export type DocumentFileType = "csv" | "pdf" | "txt" | "docx";
export type DocumentFile = {
id: string;
filename: string;
filesize: number;
filetype: DocumentFileType;
content: string | TextNode[];
};
type Annotation = {
type: string;
data: object;
};
export function retrieveNodes(annotations?: JSONValue[]): TextNode[] {
if (!annotations) return [];
const textNodes: TextNode[] = [];
annotations.forEach((annotation: JSONValue) => {
const { type, data } = getValidAnnotation(annotation);
if (
type === "document_file" &&
"files" in data &&
Array.isArray(data.files)
) {
const files = data.files as DocumentFile[];
files.forEach((file) => {
if (Array.isArray(file.content)) {
file.content.forEach((node) => {
if (node.hasOwnProperty("text")) {
textNodes.push(
new TextNode({
text: node.text,
embedding: node.embedding,
metadata: node.metadata,
}),
);
}
});
}
});
}
});
return textNodes;
}
export function convertMessageContent(
content: string,
annotations?: JSONValue[],
): MessageContent {
if (!annotations) return content;
return [
{
type: "text",
text: content,
},
...convertAnnotations(annotations),
];
}
function convertAnnotations(annotations: JSONValue[]): MessageContentDetail[] {
const content: MessageContentDetail[] = [];
annotations.forEach((annotation: JSONValue) => {
const { type, data } = getValidAnnotation(annotation);
// convert image
if (type === "image" && "url" in data && typeof data.url === "string") {
content.push({
type: "image_url",
image_url: {
url: data.url,
},
});
}
// convert the content of files to a text message
if (
type === "document_file" &&
"files" in data &&
Array.isArray(data.files)
) {
// get all CSV files and convert their whole content to one text message
// currently CSV files are the only files where we send the whole content - we don't use an index
const csvFiles = data.files.filter(
(file: DocumentFile) => file.filetype === "csv",
);
if (csvFiles && csvFiles.length > 0) {
const csvContents = csvFiles.map(
(file: DocumentFile) => "```csv\n" + file.content + "\n```",
);
const text =
"Use the following CSV content:\n" + csvContents.join("\n\n");
content.push({
type: "text",
text,
});
}
}
});
return content;
}
function getValidAnnotation(annotation: JSONValue): Annotation {
if (
!(
annotation &&
typeof annotation === "object" &&
"type" in annotation &&
typeof annotation.type === "string" &&
"data" in annotation &&
annotation.data &&
typeof annotation.data === "object"
)
) {
throw new Error("Client sent invalid annotation. Missing data and type");
}
return { type: annotation.type, data: annotation.data };
}
@@ -7,22 +7,6 @@ import {
ToolOutput,
} from "llamaindex";
function getNodeUrl(metadata: Metadata) {
const url = metadata["URL"];
if (url) return url;
const fileName = metadata["file_name"];
if (!process.env.FILESERVER_URL_PREFIX) {
console.warn(
"FILESERVER_URL_PREFIX is not set. File URLs will not be generated.",
);
return undefined;
}
if (fileName) {
return `${process.env.FILESERVER_URL_PREFIX}/data/${fileName}`;
}
return undefined;
}
export function appendSourceData(
data: StreamData,
sourceNodes?: NodeWithScore<Metadata>[],
@@ -113,9 +97,18 @@ export function createCallbackManager(stream: StreamData) {
return callbackManager;
}
export type CsvFile = {
content: string;
filename: string;
filesize: number;
id: string;
};
function getNodeUrl(metadata: Metadata) {
const url = metadata["URL"];
if (url) return url;
const fileName = metadata["file_name"];
if (!process.env.FILESERVER_URL_PREFIX) {
console.warn(
"FILESERVER_URL_PREFIX is not set. File URLs will not be generated.",
);
return undefined;
}
if (fileName) {
return `${process.env.FILESERVER_URL_PREFIX}/data/${fileName}`;
}
return undefined;
}
@@ -0,0 +1,40 @@
import {
StreamData,
createCallbacksTransformer,
createStreamDataTransformer,
trimStartOfStreamHelper,
type AIStreamCallbacksAndOptions,
} from "ai";
import { EngineResponse } from "llamaindex";
export function LlamaIndexStream(
response: AsyncIterable<EngineResponse>,
data: StreamData,
opts?: {
callbacks?: AIStreamCallbacksAndOptions;
},
): ReadableStream<Uint8Array> {
return createParser(response, data)
.pipeThrough(createCallbacksTransformer(opts?.callbacks))
.pipeThrough(createStreamDataTransformer());
}
function createParser(res: AsyncIterable<EngineResponse>, data: StreamData) {
const it = res[Symbol.asyncIterator]();
const trimStartOfStream = trimStartOfStreamHelper();
return new ReadableStream<string>({
async pull(controller): Promise<void> {
const { value, done } = await it.next();
if (done) {
controller.close();
data.close();
return;
}
const text = trimStartOfStream(value.delta ?? "");
if (text) {
controller.enqueue(text);
}
},
});
}
@@ -4,8 +4,12 @@ import { ChatMessage, Settings } from "llamaindex";
import { NextRequest, NextResponse } from "next/server";
import { createChatEngine } from "./engine/chat";
import { initSettings } from "./engine/settings";
import { LlamaIndexStream, convertMessageContent } from "./llamaindex-stream";
import { createCallbackManager, createStreamTimeout } from "./stream-helper";
import { convertMessageContent, retrieveNodes } from "./llamaindex/annotations";
import {
createCallbackManager,
createStreamTimeout,
} from "./llamaindex/events";
import { LlamaIndexStream } from "./llamaindex/stream";
initObservability();
initSettings();
@@ -32,8 +36,6 @@ export async function POST(request: NextRequest) {
);
}
const chatEngine = await createChatEngine();
let annotations = userMessage.annotations;
if (!annotations) {
// the user didn't send any new annotations with the last message
@@ -47,6 +49,10 @@ export async function POST(request: NextRequest) {
)?.annotations;
}
// retrieve nodes from annotations (if any) and create chat engine with index
const nodes = retrieveNodes(annotations);
const chatEngine = await createChatEngine(nodes);
// Convert message content from Vercel/AI format to LlamaIndex/OpenAI format
const userMessageContent = convertMessageContent(
userMessage.content,
@@ -5,7 +5,7 @@ import { ChatInput, ChatMessages } from "./ui/chat";
import { useClientConfig } from "./ui/chat/hooks/use-config";
export default function ChatSection() {
const { chatAPI } = useClientConfig();
const { backend } = useClientConfig();
const {
messages,
input,
@@ -17,7 +17,7 @@ export default function ChatSection() {
append,
setInput,
} = useChat({
api: chatAPI,
api: `${backend}/api/chat`,
headers: {
"Content-Type": "application/json", // using JSON because of vercel/ai 2.2.26
},
@@ -1,14 +1,13 @@
import { JSONValue } from "ai";
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { MessageAnnotation, MessageAnnotationType } from ".";
import { Button } from "../button";
import { DocumentPreview } from "../document-preview";
import FileUploader from "../file-uploader";
import { Input } from "../input";
import UploadCsvPreview from "../upload-csv-preview";
import UploadImagePreview from "../upload-image-preview";
import { ChatHandler } from "./chat.interface";
import { useCsv } from "./hooks/use-csv";
import { useFile } from "./hooks/use-file";
const ALLOWED_EXTENSIONS = ["png", "jpg", "jpeg", "csv", "pdf", "txt", "docx"];
export default function ChatInput(
props: Pick<
@@ -24,33 +23,15 @@ export default function ChatInput(
| "append"
>,
) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const { files: csvFiles, upload, remove, reset } = useCsv();
const getAnnotations = () => {
if (!imageUrl && csvFiles.length === 0) return undefined;
const annotations: MessageAnnotation[] = [];
if (imageUrl) {
annotations.push({
type: MessageAnnotationType.IMAGE,
data: { url: imageUrl },
});
}
if (csvFiles.length > 0) {
annotations.push({
type: MessageAnnotationType.CSV,
data: {
csvFiles: csvFiles.map((file) => ({
id: file.id,
content: file.content,
filename: file.filename,
filesize: file.filesize,
})),
},
});
}
return annotations as JSONValue[];
};
const {
imageUrl,
setImageUrl,
uploadFile,
files,
removeDoc,
reset,
getAnnotations,
} = useFile();
// default submit function does not handle including annotations in the message
// so we need to use append function to submit new message with annotations
@@ -70,61 +51,20 @@ export default function ChatInput(
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const annotations = getAnnotations();
if (annotations) {
if (annotations.length) {
handleSubmitWithAnnotations(e, annotations);
imageUrl && setImageUrl(null);
csvFiles.length && reset();
return;
return reset();
}
props.handleSubmit(e);
};
const onRemovePreviewImage = () => setImageUrl(null);
const readContent = async (file: File): Promise<string> => {
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
if (file.type.startsWith("image/")) {
reader.readAsDataURL(file);
} else {
reader.readAsText(file);
}
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
return content;
};
const handleUploadImageFile = async (file: File) => {
const base64 = await readContent(file);
setImageUrl(base64);
};
const handleUploadCsvFile = async (file: File) => {
const content = await readContent(file);
const isSuccess = upload({
id: uuidv4(),
content,
filename: file.name,
filesize: file.size,
});
if (!isSuccess) {
alert("File already exists in the list.");
}
};
const handleUploadFile = async (file: File) => {
if (imageUrl || files.length > 0) {
alert("You can only upload one file at a time.");
return;
}
try {
if (file.type.startsWith("image/")) {
return await handleUploadImageFile(file);
}
if (file.type === "text/csv") {
if (csvFiles.length > 0) {
alert("You can only upload one csv file at a time.");
return;
}
return await handleUploadCsvFile(file);
}
await uploadFile(file);
props.onFileUpload?.(file);
} catch (error: any) {
props.onFileError?.(error.message);
@@ -137,19 +77,17 @@ export default function ChatInput(
className="rounded-xl bg-white p-4 shadow-xl space-y-4 shrink-0"
>
{imageUrl && (
<UploadImagePreview url={imageUrl} onRemove={onRemovePreviewImage} />
<UploadImagePreview url={imageUrl} onRemove={() => setImageUrl(null)} />
)}
{csvFiles.length > 0 && (
{files.length > 0 && (
<div className="flex gap-4 w-full overflow-auto py-2">
{csvFiles.map((csv) => {
return (
<UploadCsvPreview
key={csv.id}
csv={csv}
onRemove={() => remove(csv)}
/>
);
})}
{files.map((file) => (
<DocumentPreview
key={file.id}
file={file}
onRemove={() => removeDoc(file)}
/>
))}
</div>
)}
<div className="flex w-full items-start justify-between gap-4 ">
@@ -164,6 +102,10 @@ export default function ChatInput(
<FileUploader
onFileUpload={handleUploadFile}
onFileError={props.onFileError}
config={{
allowedExtensions: ALLOWED_EXTENSIONS,
disabled: props.isLoading,
}}
/>
<Button type="submit" disabled={props.isLoading || !props.input.trim()}>
Send message
@@ -0,0 +1,13 @@
import { DocumentPreview } from "../../document-preview";
import { DocumentFileData } from "../index";
export function ChatFiles({ data }: { data: DocumentFileData }) {
if (!data.files.length) return null;
return (
<div className="flex gap-2 items-center">
{data.files.map((file) => (
<DocumentPreview key={file.id} file={file} />
))}
</div>
);
}
@@ -1,13 +0,0 @@
import UploadCsvPreview from "../../upload-csv-preview";
import { CsvData } from "../index";
export default function CsvContent({ data }: { data: CsvData }) {
if (!data.csvFiles.length) return null;
return (
<div className="flex gap-2 items-center">
{data.csvFiles.map((csv, index) => (
<UploadCsvPreview key={index} csv={csv} />
))}
</div>
);
}
@@ -5,7 +5,7 @@ import { Fragment } from "react";
import { Button } from "../../button";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
import {
CsvData,
DocumentFileData,
EventData,
ImageData,
MessageAnnotation,
@@ -16,10 +16,10 @@ import {
} from "../index";
import ChatAvatar from "./chat-avatar";
import { ChatEvents } from "./chat-events";
import { ChatFiles } from "./chat-files";
import { ChatImage } from "./chat-image";
import { ChatSources } from "./chat-sources";
import ChatTools from "./chat-tools";
import CsvContent from "./csv-content";
import Markdown from "./markdown";
type ContentDisplayConfig = {
@@ -41,9 +41,9 @@ function ChatMessageContent({
annotations,
MessageAnnotationType.IMAGE,
);
const csvData = getAnnotationData<CsvData>(
const contentFileData = getAnnotationData<DocumentFileData>(
annotations,
MessageAnnotationType.CSV,
MessageAnnotationType.DOCUMENT_FILE,
);
const eventData = getAnnotationData<EventData>(
annotations,
@@ -72,7 +72,9 @@ function ChatMessageContent({
},
{
order: 2,
component: csvData[0] ? <CsvContent data={csvData[0]} /> : null,
component: contentFileData[0] ? (
<ChatFiles data={contentFileData[0]} />
) : null,
},
{
order: -1,
@@ -3,22 +3,20 @@
import { useEffect, useMemo, useState } from "react";
export interface ChatConfig {
chatAPI?: string;
backend?: string;
starterQuestions?: string[];
}
export function useClientConfig() {
const API_ROUTE = "/api/chat/config";
export function useClientConfig(): ChatConfig {
const chatAPI = process.env.NEXT_PUBLIC_CHAT_API;
const [config, setConfig] = useState<ChatConfig>({
chatAPI,
});
const [config, setConfig] = useState<ChatConfig>();
const configAPI = useMemo(() => {
const backendOrigin = chatAPI ? new URL(chatAPI).origin : "";
return `${backendOrigin}${API_ROUTE}`;
const backendOrigin = useMemo(() => {
return chatAPI ? new URL(chatAPI).origin : "";
}, [chatAPI]);
const configAPI = `${backendOrigin}/api/chat/config`;
useEffect(() => {
fetch(configAPI)
.then((response) => response.json())
@@ -26,5 +24,8 @@ export function useClientConfig() {
.catch((error) => console.error("Error fetching config", error));
}, [chatAPI, configAPI]);
return config;
return {
backend: backendOrigin,
starterQuestions: config?.starterQuestions,
};
}
@@ -1,33 +0,0 @@
"use client";
import { useState } from "react";
import { CsvFile } from "../index";
export function useCsv() {
const [files, setFiles] = useState<CsvFile[]>([]);
const csvEqual = (a: CsvFile, b: CsvFile) => {
if (a.id === b.id) return true;
if (a.filename === b.filename && a.filesize === b.filesize) return true;
return false;
};
const upload = (file: CsvFile) => {
const existedCsv = files.find((f) => csvEqual(f, file));
if (!existedCsv) {
setFiles((prev) => [...prev, file]);
return true;
}
return false;
};
const remove = (file: CsvFile) => {
setFiles((prev) => prev.filter((f) => f.id !== file.id));
};
const reset = () => {
setFiles([]);
};
return { files, upload, remove, reset };
}
@@ -0,0 +1,139 @@
"use client";
import { JSONValue } from "llamaindex";
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import {
DocumentFile,
DocumentFileType,
MessageAnnotation,
MessageAnnotationType,
TextNode,
} from "..";
import { useClientConfig } from "./use-config";
const docMineTypeMap: Record<string, DocumentFileType> = {
"text/csv": "csv",
"application/pdf": "pdf",
"text/plain": "txt",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"docx",
};
export function useFile() {
const { backend } = useClientConfig();
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [files, setFiles] = useState<DocumentFile[]>([]);
const docEqual = (a: DocumentFile, b: DocumentFile) => {
if (a.id === b.id) return true;
if (a.filename === b.filename && a.filesize === b.filesize) return true;
return false;
};
const addDoc = (file: DocumentFile) => {
const existedFile = files.find((f) => docEqual(f, file));
if (!existedFile) {
setFiles((prev) => [...prev, file]);
return true;
}
return false;
};
const removeDoc = (file: DocumentFile) => {
setFiles((prev) => prev.filter((f) => f.id !== file.id));
};
const reset = () => {
imageUrl && setImageUrl(null);
files.length && setFiles([]);
};
const getTextNodes = async (base64: string): Promise<TextNode[]> => {
const embedAPI = `${backend}/api/chat/embed`;
const response = await fetch(embedAPI, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
base64,
}),
});
if (!response.ok) throw new Error("Failed to get text nodes from file.");
return await response.json();
};
const getAnnotations = () => {
const annotations: MessageAnnotation[] = [];
if (imageUrl) {
annotations.push({
type: MessageAnnotationType.IMAGE,
data: { url: imageUrl },
});
}
if (files.length > 0) {
annotations.push({
type: MessageAnnotationType.DOCUMENT_FILE,
data: { files },
});
}
return annotations as JSONValue[];
};
const readContent = async (input: {
file: File;
asUrl?: boolean;
}): Promise<string> => {
const { file, asUrl } = input;
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
if (asUrl) {
reader.readAsDataURL(file);
} else {
reader.readAsText(file);
}
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
return content;
};
const uploadFile = async (file: File) => {
if (file.type.startsWith("image/")) {
const base64 = await readContent({ file, asUrl: true });
return setImageUrl(base64);
}
const filetype = docMineTypeMap[file.type];
if (!filetype) throw new Error("Unsupported document type.");
const newDoc: DocumentFile = {
id: uuidv4(),
filetype,
filename: file.name,
filesize: file.size,
content: "",
};
switch (file.type) {
case "text/csv": {
const content = await readContent({ file });
return addDoc({ ...newDoc, content });
}
default: {
const base64 = await readContent({ file, asUrl: true });
const nodes = await getTextNodes(base64);
return addDoc({ ...newDoc, content: nodes });
}
}
};
return {
imageUrl,
setImageUrl,
files,
removeDoc,
reset,
getAnnotations,
uploadFile,
};
}
@@ -6,8 +6,8 @@ export { type ChatHandler } from "./chat.interface";
export { ChatInput, ChatMessages };
export enum MessageAnnotationType {
CSV = "csv",
IMAGE = "image",
DOCUMENT_FILE = "document_file",
SOURCES = "sources",
EVENTS = "events",
TOOLS = "tools",
@@ -17,15 +17,24 @@ export type ImageData = {
url: string;
};
export type CsvFile = {
content: string;
filename: string;
filesize: number;
id: string;
// this is subset of LlamaIndex's TextNode
export type TextNode = {
text: string;
embedding: number[];
};
export type CsvData = {
csvFiles: CsvFile[];
export type DocumentFileType = "csv" | "pdf" | "txt" | "docx";
export type DocumentFile = {
id: string;
filename: string;
filesize: number;
filetype: DocumentFileType;
content: string | TextNode[];
};
export type DocumentFileData = {
files: DocumentFile[];
};
export type SourceNode = {
@@ -61,7 +70,7 @@ export type ToolData = {
export type AnnotationData =
| ImageData
| CsvData
| DocumentFileData
| SourceData
| EventData
| ToolData;
@@ -1,8 +1,11 @@
import { XCircleIcon } from "lucide-react";
import Image from "next/image";
import DocxIcon from "../ui/icons/docx.svg";
import PdfIcon from "../ui/icons/pdf.svg";
import SheetIcon from "../ui/icons/sheet.svg";
import TxtIcon from "../ui/icons/txt.svg";
import { Button } from "./button";
import { CsvFile } from "./chat";
import { DocumentFile, DocumentFileType } from "./chat";
import {
Drawer,
DrawerClose,
@@ -14,24 +17,27 @@ import {
} from "./drawer";
import { cn } from "./lib/utils";
export interface UploadCsvPreviewProps {
csv: CsvFile;
export interface DocumentPreviewProps {
file: DocumentFile;
onRemove?: () => void;
}
export default function UploadCsvPreview(props: UploadCsvPreviewProps) {
const { filename, filesize, content } = props.csv;
export function DocumentPreview(props: DocumentPreviewProps) {
const { filename, filesize, content, filetype } = props.file;
const docContent = Array.isArray(content)
? content.map((n) => n.text).join("\n")
: content;
return (
<Drawer direction="left">
<DrawerTrigger asChild>
<div>
<CSVSummaryCard {...props} />
<PreviewCard {...props} />
</div>
</DrawerTrigger>
<DrawerContent className="w-3/5 mt-24 h-full max-h-[96%] ">
<DrawerHeader className="flex justify-between">
<div className="space-y-2">
<DrawerTitle>Csv Raw Content</DrawerTitle>
<DrawerTitle>{filetype.toUpperCase()} Raw Content</DrawerTitle>
<DrawerDescription>
{filename} ({inKB(filesize)} KB)
</DrawerDescription>
@@ -42,7 +48,7 @@ export default function UploadCsvPreview(props: UploadCsvPreviewProps) {
</DrawerHeader>
<div className="m-4 max-h-[80%] overflow-auto">
<pre className="bg-secondary rounded-md p-4 block text-sm">
{content}
{docContent}
</pre>
</div>
</DrawerContent>
@@ -50,25 +56,32 @@ export default function UploadCsvPreview(props: UploadCsvPreviewProps) {
);
}
function CSVSummaryCard(props: UploadCsvPreviewProps) {
const { onRemove, csv } = props;
const FileIcon: Record<DocumentFileType, string> = {
csv: SheetIcon,
pdf: PdfIcon,
docx: DocxIcon,
txt: TxtIcon,
};
function PreviewCard(props: DocumentPreviewProps) {
const { onRemove, file } = props;
return (
<div className="p-2 w-60 max-w-60 bg-secondary rounded-lg text-sm relative cursor-pointer">
<div className="flex flex-row items-center gap-2">
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md">
<div className="relative h-8 w-8 shrink-0 overflow-hidden rounded-md">
<Image
className="h-full w-auto"
priority
src={SheetIcon}
alt="SheetIcon"
src={FileIcon[file.filetype]}
alt="Icon"
/>
</div>
<div className="overflow-hidden">
<div className="truncate font-semibold">
{csv.filename} ({inKB(csv.filesize)} KB)
{file.filename} ({inKB(file.filesize)} KB)
</div>
<div className="truncate text-token-text-tertiary flex items-center gap-2">
<span>Spreadsheet</span>
<span>{file.filetype.toUpperCase()} File</span>
</div>
</div>
</div>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd">
<path d="m5.11 0a5.07 5.07 0 0 0 -5.11 5v53.88a5.07 5.07 0 0 0 5.11 5.12h45.78a5.07 5.07 0 0 0 5.11-5.12v-38.6l-18.94-20.28z" fill="#107cad"/>
<path d="m56 20.35v1h-12.82s-6.31-1.26-6.13-6.71c0 0 .21 5.71 6 5.71z" fill="#084968"/>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 309.267 309.267" xml:space="preserve">
<g>
<path style="fill:#E2574C;" d="M38.658,0h164.23l87.049,86.711v203.227c0,10.679-8.659,19.329-19.329,19.329H38.658
c-10.67,0-19.329-8.65-19.329-19.329V19.329C19.329,8.65,27.989,0,38.658,0z"/>
<path style="fill:#B53629;" d="M289.658,86.981h-67.372c-10.67,0-19.329-8.659-19.329-19.329V0.193L289.658,86.981z"/>
<path style="fill:#FFFFFF;" d="M217.434,146.544c3.238,0,4.823-2.822,4.823-5.557c0-2.832-1.653-5.567-4.823-5.567h-18.44
c-3.605,0-5.615,2.986-5.615,6.282v45.317c0,4.04,2.3,6.282,5.412,6.282c3.093,0,5.403-2.242,5.403-6.282v-12.438h11.153
c3.46,0,5.19-2.832,5.19-5.644c0-2.754-1.73-5.49-5.19-5.49h-11.153v-16.903C204.194,146.544,217.434,146.544,217.434,146.544z
M155.107,135.42h-13.492c-3.663,0-6.263,2.513-6.263,6.243v45.395c0,4.629,3.74,6.079,6.417,6.079h14.159
c16.758,0,27.824-11.027,27.824-28.047C183.743,147.095,173.325,135.42,155.107,135.42z M155.755,181.946h-8.225v-35.334h7.413
c11.221,0,16.101,7.529,16.101,17.918C171.044,174.253,166.25,181.946,155.755,181.946z M106.33,135.42H92.964
c-3.779,0-5.886,2.493-5.886,6.282v45.317c0,4.04,2.416,6.282,5.663,6.282s5.663-2.242,5.663-6.282v-13.231h8.379
c10.341,0,18.875-7.326,18.875-19.107C125.659,143.152,117.425,135.42,106.33,135.42z M106.108,163.158h-7.703v-17.097h7.703
c4.755,0,7.78,3.711,7.78,8.553C113.878,159.447,110.863,163.158,106.108,163.158z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<path style="fill:#E2E5E7;" d="M128,0c-17.6,0-32,14.4-32,32v448c0,17.6,14.4,32,32,32h320c17.6,0,32-14.4,32-32V128L352,0H128z"/>
<path style="fill:#B0B7BD;" d="M384,128h96L352,0v96C352,113.6,366.4,128,384,128z"/>
<polygon style="fill:#CAD1D8;" points="480,224 384,128 480,128 "/>
<path style="fill:#576D7E;" d="M416,416c0,8.8-7.2,16-16,16H48c-8.8,0-16-7.2-16-16V256c0-8.8,7.2-16,16-16h352c8.8,0,16,7.2,16,16
V416z"/>
<g>
<path style="fill:#FFFFFF;" d="M132.784,311.472H110.4c-11.136,0-11.136-16.368,0-16.368h60.512c11.392,0,11.392,16.368,0,16.368
h-21.248v64.592c0,11.12-16.896,11.392-16.896,0v-64.592H132.784z"/>
<path style="fill:#FFFFFF;" d="M224.416,326.176l22.272-27.888c6.656-8.688,19.568,2.432,12.288,10.752
c-7.68,9.088-15.728,18.944-23.424,29.024l26.112,32.496c7.024,9.6-7.04,18.816-13.952,9.344l-23.536-30.192l-23.152,30.832
c-6.528,9.328-20.992-1.152-13.68-9.856l25.696-32.624c-8.048-10.096-15.856-19.936-23.664-29.024
c-8.064-9.6,6.912-19.44,12.784-10.48L224.416,326.176z"/>
<path style="fill:#FFFFFF;" d="M298.288,311.472H275.92c-11.136,0-11.136-16.368,0-16.368h60.496c11.392,0,11.392,16.368,0,16.368
h-21.232v64.592c0,11.12-16.896,11.392-16.896,0V311.472z"/>
</g>
<path style="fill:#CAD1D8;" d="M400,432H96v16h304c8.8,0,16-7.2,16-16v-16C416,424.8,408.8,432,400,432z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -3,7 +3,7 @@ import ChatSection from "./components/chat-section";
export default function Home() {
return (
<main className="h-full w-full flex justify-center items-center background-gradient">
<main className="h-screen w-screen flex justify-center items-center background-gradient">
<div className="space-y-2 lg:space-y-10 w-[90%] lg:w-[60rem]">
<Header />
<div className="h-[65vh] flex">
@@ -27,7 +27,6 @@
"llamaindex": "0.4.6",
"lucide-react": "^0.294.0",
"next": "^14.2.4",
"pdf2json": "3.0.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.7",