Compare commits

...

4 Commits

Author SHA1 Message Date
Thuc Pham 0a2c48fd50 rename type and make it scrollable 2024-06-01 09:15:01 +07:00
Thuc Pham 508eebe39c feat: support multiple csv upload and reuse 2024-06-01 09:15:01 +07:00
Thuc Pham f9a4f7644a hide uploaded csv files if choose a new one 2024-06-01 09:15:01 +07:00
Thuc Pham 34f95bfa29 remove all multiModal props 2024-06-01 09:15:01 +07:00
15 changed files with 248 additions and 164 deletions
@@ -14,7 +14,6 @@ export interface ChatInputProps {
/** Form submission handler to automatically reset input and append a user message */
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
isLoading: boolean;
multiModal?: boolean;
messages: Message[];
}
@@ -47,7 +47,7 @@ export const chat = async (req: Request, res: Response) => {
const stream = LlamaIndexStream(response, vercelStreamData, {
parserOptions: {
imageUrl: data?.imageUrl,
uploadedCsv: data?.uploadedCsv,
csvFiles: data?.csvFiles,
},
});
@@ -15,7 +15,7 @@ import {
import { AgentStreamChatResponse } from "llamaindex/agent/base";
import {
UploadedCsv,
CsvFile,
appendCsvData,
appendImageData,
appendSourceData,
@@ -27,7 +27,7 @@ type LlamaIndexResponse =
export type DataParserOptions = {
imageUrl?: string;
uploadedCsv?: UploadedCsv;
csvFiles?: CsvFile[];
};
export const convertMessageContent = (
@@ -50,12 +50,12 @@ export const convertMessageContent = (
});
}
if (additionalData?.uploadedCsv) {
if (additionalData?.csvFiles?.length) {
const rawContents = additionalData.csvFiles.map((csv) => {
return "```csv\n" + csv.content + "\n```";
});
const csvContent =
"Use the following CSV data:\n" +
"```csv\n" +
additionalData.uploadedCsv.content +
"\n```";
"Use data from following CSV raw contents:\n" + rawContents.join("\n\n");
content.push({
type: "text",
text: `${csvContent}\n\n${textMessage}`,
@@ -77,7 +77,7 @@ function createParser(
return new ReadableStream<string>({
start() {
appendImageData(data, opts?.imageUrl);
appendCsvData(data, opts?.uploadedCsv);
appendCsvData(data, opts?.csvFiles);
},
async pull(controller): Promise<void> {
const { value, done } = await it.next();
@@ -122,16 +122,19 @@ export function createCallbackManager(stream: StreamData) {
return callbackManager;
}
export type UploadedCsv = {
export type CsvFile = {
content: string;
filename: string;
filesize: number;
id: string;
};
export function appendCsvData(data: StreamData, uploadedCsv?: UploadedCsv) {
if (!uploadedCsv) return;
export function appendCsvData(data: StreamData, csvFiles?: CsvFile[]) {
if (!csvFiles) return;
data.appendMessageAnnotation({
type: "csv",
data: uploadedCsv,
data: {
csvFiles,
},
});
}
@@ -15,7 +15,7 @@ import {
import { AgentStreamChatResponse } from "llamaindex/agent/base";
import {
UploadedCsv,
CsvFile,
appendCsvData,
appendImageData,
appendSourceData,
@@ -27,7 +27,7 @@ type LlamaIndexResponse =
export type DataParserOptions = {
imageUrl?: string;
uploadedCsv?: UploadedCsv;
csvFiles?: CsvFile[];
};
export const convertMessageContent = (
@@ -50,12 +50,12 @@ export const convertMessageContent = (
});
}
if (additionalData?.uploadedCsv) {
if (additionalData?.csvFiles?.length) {
const rawContents = additionalData.csvFiles.map((csv) => {
return "```csv\n" + csv.content + "\n```";
});
const csvContent =
"Use the following CSV data:\n" +
"```csv\n" +
additionalData.uploadedCsv.content +
"\n```";
"Use data from following CSV raw contents:\n" + rawContents.join("\n\n");
content.push({
type: "text",
text: `${csvContent}\n\n${textMessage}`,
@@ -77,7 +77,7 @@ function createParser(
return new ReadableStream<string>({
start() {
appendImageData(data, opts?.imageUrl);
appendCsvData(data, opts?.uploadedCsv);
appendCsvData(data, opts?.csvFiles);
},
async pull(controller): Promise<void> {
const { value, done } = await it.next();
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
const stream = LlamaIndexStream(response, vercelStreamData, {
parserOptions: {
imageUrl: data?.imageUrl,
uploadedCsv: data?.uploadedCsv,
csvFiles: data?.csvFiles,
},
});
@@ -122,16 +122,19 @@ export function createCallbackManager(stream: StreamData) {
return callbackManager;
}
export type UploadedCsv = {
export type CsvFile = {
content: string;
filename: string;
filesize: number;
id: string;
};
export function appendCsvData(data: StreamData, uploadedCsv?: UploadedCsv) {
if (!uploadedCsv) return;
export function appendCsvData(data: StreamData, csvFiles?: CsvFile[]) {
if (!csvFiles) return;
data.appendMessageAnnotation({
type: "csv",
data: uploadedCsv,
data: {
csvFiles,
},
});
}
@@ -1,12 +1,13 @@
import { useEffect, useState } from "react";
import { CsvData, getInputResources } from ".";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { Button } from "../button";
import FileUploader from "../file-uploader";
import { Input } from "../input";
import UploadCsvPreview from "../upload-csv-preview";
import UploadImagePreview from "../upload-image-preview";
import ChatResources from "./chat-resources";
import { ChatHandler } from "./chat.interface";
import { useCsv } from "./use-csv";
export default function ChatInput(
props: Pick<
@@ -21,17 +22,9 @@ export default function ChatInput(
>,
) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [uploadedCsv, setUploadedCsv] = useState<CsvData>();
const [inputResources, setInputResources] = useState<
Array<CsvData & { selected: boolean }>
>([]);
useEffect(() => {
const resources = getInputResources(props.messages);
setInputResources(
resources.csv.map((data) => ({ ...data, selected: true })),
);
}, [props.messages]);
const { files, uploadNew, removeFile, resetUploadedFiles } = useCsv(
props.messages,
);
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (imageUrl) {
@@ -41,21 +34,12 @@ export default function ChatInput(
setImageUrl(null);
return;
}
// if users upload a new csv file, we will send it to backend
if (uploadedCsv) {
props.handleSubmit(e, {
data: { uploadedCsv },
});
setUploadedCsv(undefined);
return;
}
// if users upload a new csv file, we can reuse provided csv resources
const attachCsv = inputResources.filter((r) => r.selected)[0];
if (attachCsv) {
if (files.length > 0) {
props.handleSubmit(e, {
data: { uploadedCsv: attachCsv },
data: { csvFiles: files },
});
resetUploadedFiles();
return;
}
@@ -81,11 +65,15 @@ export default function ChatInput(
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
setUploadedCsv({
const isSuccess = uploadNew({
id: uuidv4(),
content,
filename: file.name,
filesize: file.size,
});
if (!isSuccess) {
alert("File already exists in the list.");
}
};
const handleUploadFile = async (file: File) => {
@@ -102,33 +90,37 @@ export default function ChatInput(
}
};
const removeResource = (index: number) => {
setInputResources((resources) => {
const newResources = [...resources];
newResources[index].selected = false;
return newResources;
});
};
return (
<form
onSubmit={onSubmit}
className="rounded-xl bg-white p-4 shadow-xl space-y-4"
>
<ChatResources
isLoading={props.isLoading}
resources={inputResources}
removeResource={removeResource}
/>
{imageUrl && (
<UploadImagePreview url={imageUrl} onRemove={onRemovePreviewImage} />
)}
{uploadedCsv && (
<UploadCsvPreview
filename={uploadedCsv.filename}
filesize={uploadedCsv.filesize}
onRemove={() => setUploadedCsv(undefined)}
/>
{files.length > 0 && (
<div className="flex gap-4 w-full overflow-auto py-2">
{props.isLoading ? (
<div className="flex gap-2 items-center">
<Loader2 className="h-4 w-4 animate-spin" />{" "}
<span>Handling csv files...</span>
</div>
) : (
<>
{files.map((csv) => {
return (
<UploadCsvPreview
key={csv.id}
filename={csv.filename}
filesize={csv.filesize}
onRemove={() => removeFile(csv)}
isNew={csv.type === "new_upload"}
/>
);
})}
</>
)}
</div>
)}
<div className="flex w-full items-start justify-between gap-4 ">
<Input
@@ -1,48 +0,0 @@
import { Loader2, XIcon } from "lucide-react";
import Image from "next/image";
import { CsvData } from ".";
import SheetIcon from "../../ui/icons/sheet.svg";
export interface ChatResourcesProps {
isLoading: boolean;
resources: Array<CsvData & { selected: boolean }>;
removeResource: (index: number) => void;
}
export default function ChatResources(props: ChatResourcesProps) {
if (!props.resources.length) return null;
return (
<div className="flex gap-4 text-sm">
{props.resources.map((data, index) => {
if (!data.selected) return null;
const fileSizeInKB = Math.round((data.filesize / 1024) * 10) / 10;
return (
<div
className="border-2 border-green-700 py-2 px-3 rounded-lg flex gap-2 items-center"
key={data.filename}
>
<div className="h-4 w-4 shrink-0 rounded-md">
<Image
className="h-full w-auto"
priority
src={SheetIcon}
alt="SheetIcon"
/>
</div>
<span>
{data.filename} - {fileSizeInKB} KB
</span>
{props.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<XIcon
className="w-4 h-4 cursor-pointer"
onClick={() => props.removeResource(index)}
/>
)}
</div>
);
})}
</div>
);
}
@@ -1,15 +1,16 @@
import { CsvData } from ".";
const LIMIT_DISPLAY = 100; // Limit the display of CSV content to 100 characters
import CsvDialog from "./widgets/CsvDialog";
export default function CsvContent({ data }: { data: CsvData }) {
const summaryContent = data.content.slice(0, LIMIT_DISPLAY) + "...";
if (!data.csvFiles.length) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold">CSV Raw Content</h3>
<pre className="bg-secondary max-h-[200px] overflow-auto rounded-md p-4 block text-sm">
{summaryContent}
</pre>
<div>
<p className="font-semibold mb-2">Using data from following CSV files:</p>
<div className="flex gap-2 items-center">
{data.csvFiles.map((csv, index) => (
<CsvDialog key={index} csv={csv} />
))}
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { JSONValue, Message } from "ai";
import { JSONValue } from "ai";
import ChatInput from "./chat-input";
import ChatMessages from "./chat-messages";
@@ -17,10 +17,15 @@ export type ImageData = {
url: string;
};
export type CsvData = {
export type CsvFile = {
content: string;
filename: string;
filesize: number;
id: string;
};
export type CsvData = {
csvFiles: CsvFile[];
};
export type SourceNode = {
@@ -72,32 +77,3 @@ export function getAnnotationData<T extends AnnotationData>(
): T[] {
return annotations.filter((a) => a.type === type).map((a) => a.data as T);
}
// this function is used to get the additional resources for a message
// it filters the annotations of a message and returns the unique resources
// currently only CSV resources are supported
export const getInputResources = (
messages: Message[],
): {
csv: Array<CsvData>;
} => {
const csvResources: CsvData[] = [];
messages.forEach((message) => {
if (message.annotations) {
const csvData = getAnnotationData<CsvData>(
message.annotations as MessageAnnotation[],
MessageAnnotationType.CSV,
);
csvData.forEach((data) => {
if (
csvResources.findIndex((r) => r.filename === data.filename) === -1
) {
csvResources.push(data);
}
});
}
});
return {
csv: csvResources,
};
};
@@ -0,0 +1,85 @@
"use client";
import { Message } from "ai";
import { useEffect, useMemo, useState } from "react";
import {
CsvData,
CsvFile,
MessageAnnotation,
MessageAnnotationType,
getAnnotationData,
} from ".";
interface FrontendCSVData extends CsvFile {
type: "available" | "new_upload";
}
export function useCsv(messages: Message[]) {
const [availableFiles, setAvailableFiles] = useState<FrontendCSVData[]>([]);
const [uploadedFiles, setUploadedFiles] = useState<FrontendCSVData[]>([]);
const files = useMemo(() => {
return [...availableFiles, ...uploadedFiles];
}, [availableFiles, uploadedFiles]);
useEffect(() => {
const items = getAvailableCsvFiles(messages);
setAvailableFiles(items.map((data) => ({ ...data, type: "available" })));
}, [messages]);
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;
};
// Get available csv files from annotations chat history
// returns the unique csv files by id
const getAvailableCsvFiles = (messages: Message[]): Array<CsvFile> => {
const docHash: Record<string, CsvFile> = {};
messages.forEach((message) => {
if (message.annotations) {
const csvData = getAnnotationData<CsvData>(
message.annotations as MessageAnnotation[],
MessageAnnotationType.CSV,
);
csvData.forEach((data) => {
data.csvFiles.forEach((file) => {
if (!docHash[file.id]) {
docHash[file.id] = file;
}
});
});
}
});
return Object.values(docHash);
};
const uploadNew = (file: CsvFile) => {
const existedCsv = files.find((f) => csvEqual(f, file));
if (!existedCsv) {
setUploadedFiles((prev) => [...prev, { ...file, type: "new_upload" }]);
return true;
}
return false;
};
const removeFile = (file: FrontendCSVData) => {
if (file.type === "new_upload") {
setUploadedFiles((prev) => prev.filter((f) => f.id !== file.id));
} else {
setAvailableFiles((prev) => prev.filter((f) => f.id !== file.id));
}
};
const resetUploadedFiles = () => {
setUploadedFiles([]);
};
return {
files,
uploadNew,
removeFile,
resetUploadedFiles,
};
}
@@ -0,0 +1,62 @@
import Image from "next/image";
import { CsvFile } from "..";
import SheetIcon from "../../../ui/icons/sheet.svg";
import { Button } from "../../button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "../../drawer";
export interface CsvDialogProps {
csv: CsvFile;
}
export default function CsvDialog(props: CsvDialogProps) {
const { filename, filesize, content } = props.csv;
const fileSizeInKB = Math.round((filesize / 1024) * 10) / 10;
return (
<Drawer direction="left">
<DrawerTrigger asChild>
<div
className="border-2 border-green-700 py-1.5 px-3 rounded-lg flex gap-2 items-center cursor-pointer text-sm hover:bg-green-700 hover:text-white transition-colors duration-200 ease-in-out"
key={filename}
>
<div className="h-4 w-4 shrink-0 rounded-md">
<Image
className="h-full w-auto"
priority
src={SheetIcon}
alt="SheetIcon"
/>
</div>
<span>
{filename} - {fileSizeInKB} KB
</span>
</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>
<DrawerDescription>
{filename} ({fileSizeInKB} KB)
</DrawerDescription>
</div>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerHeader>
<div className="m-4 max-h-[80%] overflow-auto">
<pre className="bg-secondary rounded-md p-4 block text-sm">
{content}
</pre>
</div>
</DrawerContent>
</Drawer>
);
}
@@ -7,14 +7,16 @@ export default function UploadCsvPreview({
filename,
filesize,
onRemove,
isNew,
}: {
filename: string;
filesize: number;
onRemove: () => void;
isNew?: boolean;
}) {
const fileSizeInKB = Math.round((filesize / 1024) * 10) / 10;
return (
<div className="p-2 w-80 bg-secondary rounded-lg text-sm relative">
<div className="p-2 w-60 max-w-60 bg-secondary rounded-lg text-sm relative">
<div className="flex flex-row items-center gap-2">
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md">
<Image
@@ -28,7 +30,14 @@ export default function UploadCsvPreview({
<div className="truncate font-semibold">
{filename} ({fileSizeInKB} KB)
</div>
<div className="truncate text-token-text-tertiary">Spreadsheet</div>
<div className="truncate text-token-text-tertiary flex items-center gap-2">
<span>Spreadsheet</span>
{isNew && (
<span className="px-2 py-0.5 bg-red-400 text-white text-xs rounded-2xl">
new
</span>
)}
</div>
</div>
</div>
<div
@@ -35,7 +35,8 @@
"tailwind-merge": "^2.1.0",
"vaul": "^0.9.1",
"@llamaindex/pdf-viewer": "^1.1.1",
"@e2b/code-interpreter": "^0.0.5"
"@e2b/code-interpreter": "^0.0.5",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20.10.3",
@@ -52,6 +53,7 @@
"prettier-plugin-organize-imports": "^3.2.4",
"tailwindcss": "^3.3.6",
"tsx": "^4.7.2",
"typescript": "^5.3.2"
"typescript": "^5.3.2",
"@types/uuid": "^9.0.8"
}
}