From a42fa53a6b37a278e7d9ef2fccb686f1721357b9 Mon Sep 17 00:00:00 2001 From: Thuc Pham <51660321+thucpn@users.noreply.github.com> Date: Thu, 30 May 2024 10:38:54 +0700 Subject: [PATCH] feat: implement csv upload (#96) * feat: implement interpreter tool * build tool system prompt * refactor: use local file system, use absolute resource url * fix: typo * feat: implement csv upload * remove dead code * fix lint * update icon & fix code review * fix lint * Update .gitignore * Update pre-commit * add timeout for streaming * Create bright-turkeys-melt.md * remove multi modal prop * suggest csv resources from frontend annotation data * get resouces inside chat input * resolve conflict * update convert message content * fix lint * feat: limit display --------- Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de> --- .changeset/bright-turkeys-melt.md | 5 ++ helpers/env-variables.ts | 6 ++ .../components/ui/html/chat/chat-input.tsx | 3 + .../src/controllers/chat.controller.ts | 50 ++++------- .../src/controllers/llamaindex-stream.ts | 55 ++++++++++-- .../express/src/controllers/stream-helper.ts | 23 +++++ .../nextjs/app/api/chat/llamaindex-stream.ts | 55 ++++++++++-- .../streaming/nextjs/app/api/chat/route.ts | 51 +++++------ .../nextjs/app/api/chat/stream-helper.ts | 23 +++++ .../nextjs/app/components/chat-section.tsx | 2 +- .../app/components/ui/chat/chat-input.tsx | 78 ++++++++++++++-- .../app/components/ui/chat/chat-message.tsx | 23 ++--- .../app/components/ui/chat/chat-resources.tsx | 48 ++++++++++ .../app/components/ui/chat/csv-content.tsx | 15 ++++ .../nextjs/app/components/ui/chat/index.ts | 52 ++++++++++- .../nextjs/app/components/ui/icons/sheet.svg | 90 +++++++++++++++++++ .../app/components/ui/upload-csv-preview.tsx | 46 ++++++++++ 17 files changed, 533 insertions(+), 92 deletions(-) create mode 100644 .changeset/bright-turkeys-melt.md create mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-resources.tsx create mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/csv-content.tsx create mode 100644 templates/types/streaming/nextjs/app/components/ui/icons/sheet.svg create mode 100644 templates/types/streaming/nextjs/app/components/ui/upload-csv-preview.tsx diff --git a/.changeset/bright-turkeys-melt.md b/.changeset/bright-turkeys-melt.md new file mode 100644 index 00000000..43d04265 --- /dev/null +++ b/.changeset/bright-turkeys-melt.md @@ -0,0 +1,5 @@ +--- +"create-llama": patch +--- + +Add CSV upload diff --git a/helpers/env-variables.ts b/helpers/env-variables.ts index 7f315fe7..8046db00 100644 --- a/helpers/env-variables.ts +++ b/helpers/env-variables.ts @@ -276,6 +276,12 @@ const getEngineEnvs = (): EnvVar[] => { "The number of similar embeddings to return when retrieving documents.", value: "3", }, + { + name: "STREAM_TIMEOUT", + description: + "The time in milliseconds to wait for the stream to return a response.", + value: "60000", + }, ]; }; diff --git a/templates/components/ui/html/chat/chat-input.tsx b/templates/components/ui/html/chat/chat-input.tsx index 7c3e8728..efb24c4e 100644 --- a/templates/components/ui/html/chat/chat-input.tsx +++ b/templates/components/ui/html/chat/chat-input.tsx @@ -1,5 +1,7 @@ "use client"; +import { Message } from "./chat-messages"; + export interface ChatInputProps { /** The current value of the input */ input?: string; @@ -13,6 +15,7 @@ export interface ChatInputProps { handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void; isLoading: boolean; multiModal?: boolean; + messages: Message[]; } export default function ChatInput(props: ChatInputProps) { diff --git a/templates/types/streaming/express/src/controllers/chat.controller.ts b/templates/types/streaming/express/src/controllers/chat.controller.ts index c96ded0d..27ba093e 100644 --- a/templates/types/streaming/express/src/controllers/chat.controller.ts +++ b/templates/types/streaming/express/src/controllers/chat.controller.ts @@ -1,32 +1,23 @@ import { Message, StreamData, streamToResponse } from "ai"; import { Request, Response } from "express"; -import { ChatMessage, MessageContent, Settings } from "llamaindex"; +import { ChatMessage, Settings } from "llamaindex"; import { createChatEngine } from "./engine/chat"; -import { LlamaIndexStream } from "./llamaindex-stream"; -import { createCallbackManager } from "./stream-helper"; - -const convertMessageContent = ( - textMessage: string, - imageUrl: string | undefined, -): MessageContent => { - if (!imageUrl) return textMessage; - return [ - { - type: "text", - text: textMessage, - }, - { - type: "image_url", - image_url: { - url: imageUrl, - }, - }, - ]; -}; +import { + DataParserOptions, + LlamaIndexStream, + convertMessageContent, +} from "./llamaindex-stream"; +import { createCallbackManager, createStreamTimeout } from "./stream-helper"; export const chat = async (req: Request, res: Response) => { + // Init Vercel AI StreamData and timeout + const vercelStreamData = new StreamData(); + const streamTimeout = createStreamTimeout(vercelStreamData); try { - const { messages, data }: { messages: Message[]; data: any } = req.body; + const { + messages, + data, + }: { messages: Message[]; data: DataParserOptions | undefined } = req.body; const userMessage = messages.pop(); if (!messages || !userMessage || userMessage.role !== "user") { return res.status(400).json({ @@ -38,13 +29,7 @@ export const chat = async (req: Request, res: Response) => { const chatEngine = await createChatEngine(); // Convert message content from Vercel/AI format to LlamaIndex/OpenAI format - const userMessageContent = convertMessageContent( - userMessage.content, - data?.imageUrl, - ); - - // Init Vercel AI StreamData - const vercelStreamData = new StreamData(); + const userMessageContent = convertMessageContent(userMessage.content, data); // Setup callbacks const callbackManager = createCallbackManager(vercelStreamData); @@ -61,7 +46,8 @@ export const chat = async (req: Request, res: Response) => { // Return a stream, which can be consumed by the Vercel/AI client const stream = LlamaIndexStream(response, vercelStreamData, { parserOptions: { - image_url: data?.imageUrl, + imageUrl: data?.imageUrl, + uploadedCsv: data?.uploadedCsv, }, }); @@ -71,5 +57,7 @@ export const chat = async (req: Request, res: Response) => { return res.status(500).json({ detail: (error as Error).message, }); + } finally { + clearTimeout(streamTimeout); } }; diff --git a/templates/types/streaming/express/src/controllers/llamaindex-stream.ts b/templates/types/streaming/express/src/controllers/llamaindex-stream.ts index 68ad22a0..4dcd584a 100644 --- a/templates/types/streaming/express/src/controllers/llamaindex-stream.ts +++ b/templates/types/streaming/express/src/controllers/llamaindex-stream.ts @@ -6,6 +6,7 @@ import { type AIStreamCallbacksAndOptions, } from "ai"; import { + MessageContent, Metadata, NodeWithScore, Response, @@ -13,20 +14,61 @@ import { } from "llamaindex"; import { AgentStreamChatResponse } from "llamaindex/agent/base"; -import { appendImageData, appendSourceData } from "./stream-helper"; +import { + UploadedCsv, + appendCsvData, + appendImageData, + appendSourceData, +} from "./stream-helper"; type LlamaIndexResponse = | AgentStreamChatResponse<ToolCallLLMMessageOptions> | Response; -type ParserOptions = { - image_url?: string; +export type DataParserOptions = { + imageUrl?: string; + uploadedCsv?: UploadedCsv; +}; + +export const convertMessageContent = ( + textMessage: string, + additionalData?: DataParserOptions, +): MessageContent => { + if (!additionalData) return textMessage; + const content: MessageContent = [ + { + type: "text", + text: textMessage, + }, + ]; + if (additionalData?.imageUrl) { + content.push({ + type: "image_url", + image_url: { + url: additionalData?.imageUrl, + }, + }); + } + + if (additionalData?.uploadedCsv) { + const csvContent = + "Use the following CSV data:\n" + + "```csv\n" + + additionalData.uploadedCsv.content + + "\n```"; + content.push({ + type: "text", + text: `${csvContent}\n\n${textMessage}`, + }); + } + + return content; }; function createParser( res: AsyncIterable<LlamaIndexResponse>, data: StreamData, - opts?: ParserOptions, + opts?: DataParserOptions, ) { const it = res[Symbol.asyncIterator](); const trimStartOfStream = trimStartOfStreamHelper(); @@ -34,7 +76,8 @@ function createParser( let sourceNodes: NodeWithScore<Metadata>[] | undefined; return new ReadableStream<string>({ start() { - appendImageData(data, opts?.image_url); + appendImageData(data, opts?.imageUrl); + appendCsvData(data, opts?.uploadedCsv); }, async pull(controller): Promise<void> { const { value, done } = await it.next(); @@ -72,7 +115,7 @@ export function LlamaIndexStream( data: StreamData, opts?: { callbacks?: AIStreamCallbacksAndOptions; - parserOptions?: ParserOptions; + parserOptions?: DataParserOptions; }, ): ReadableStream<Uint8Array> { return createParser(response, data, opts?.parserOptions) diff --git a/templates/types/streaming/express/src/controllers/stream-helper.ts b/templates/types/streaming/express/src/controllers/stream-helper.ts index ffc5dfc5..a112d6b7 100644 --- a/templates/types/streaming/express/src/controllers/stream-helper.ts +++ b/templates/types/streaming/express/src/controllers/stream-helper.ts @@ -82,6 +82,15 @@ export function appendToolData( }); } +export function createStreamTimeout(stream: StreamData) { + const timeout = Number(process.env.STREAM_TIMEOUT ?? 1000 * 60 * 5); // default to 5 minutes + const t = setTimeout(() => { + appendEventData(stream, `Stream timed out after ${timeout / 1000} seconds`); + stream.close(); + }, timeout); + return t; +} + export function createCallbackManager(stream: StreamData) { const callbackManager = new CallbackManager(); @@ -112,3 +121,17 @@ export function createCallbackManager(stream: StreamData) { return callbackManager; } + +export type UploadedCsv = { + content: string; + filename: string; + filesize: number; +}; + +export function appendCsvData(data: StreamData, uploadedCsv?: UploadedCsv) { + if (!uploadedCsv) return; + data.appendMessageAnnotation({ + type: "csv", + data: uploadedCsv, + }); +} diff --git a/templates/types/streaming/nextjs/app/api/chat/llamaindex-stream.ts b/templates/types/streaming/nextjs/app/api/chat/llamaindex-stream.ts index 68ad22a0..4dcd584a 100644 --- a/templates/types/streaming/nextjs/app/api/chat/llamaindex-stream.ts +++ b/templates/types/streaming/nextjs/app/api/chat/llamaindex-stream.ts @@ -6,6 +6,7 @@ import { type AIStreamCallbacksAndOptions, } from "ai"; import { + MessageContent, Metadata, NodeWithScore, Response, @@ -13,20 +14,61 @@ import { } from "llamaindex"; import { AgentStreamChatResponse } from "llamaindex/agent/base"; -import { appendImageData, appendSourceData } from "./stream-helper"; +import { + UploadedCsv, + appendCsvData, + appendImageData, + appendSourceData, +} from "./stream-helper"; type LlamaIndexResponse = | AgentStreamChatResponse<ToolCallLLMMessageOptions> | Response; -type ParserOptions = { - image_url?: string; +export type DataParserOptions = { + imageUrl?: string; + uploadedCsv?: UploadedCsv; +}; + +export const convertMessageContent = ( + textMessage: string, + additionalData?: DataParserOptions, +): MessageContent => { + if (!additionalData) return textMessage; + const content: MessageContent = [ + { + type: "text", + text: textMessage, + }, + ]; + if (additionalData?.imageUrl) { + content.push({ + type: "image_url", + image_url: { + url: additionalData?.imageUrl, + }, + }); + } + + if (additionalData?.uploadedCsv) { + const csvContent = + "Use the following CSV data:\n" + + "```csv\n" + + additionalData.uploadedCsv.content + + "\n```"; + content.push({ + type: "text", + text: `${csvContent}\n\n${textMessage}`, + }); + } + + return content; }; function createParser( res: AsyncIterable<LlamaIndexResponse>, data: StreamData, - opts?: ParserOptions, + opts?: DataParserOptions, ) { const it = res[Symbol.asyncIterator](); const trimStartOfStream = trimStartOfStreamHelper(); @@ -34,7 +76,8 @@ function createParser( let sourceNodes: NodeWithScore<Metadata>[] | undefined; return new ReadableStream<string>({ start() { - appendImageData(data, opts?.image_url); + appendImageData(data, opts?.imageUrl); + appendCsvData(data, opts?.uploadedCsv); }, async pull(controller): Promise<void> { const { value, done } = await it.next(); @@ -72,7 +115,7 @@ export function LlamaIndexStream( data: StreamData, opts?: { callbacks?: AIStreamCallbacksAndOptions; - parserOptions?: ParserOptions; + parserOptions?: DataParserOptions; }, ): ReadableStream<Uint8Array> { return createParser(response, data, opts?.parserOptions) diff --git a/templates/types/streaming/nextjs/app/api/chat/route.ts b/templates/types/streaming/nextjs/app/api/chat/route.ts index 86ab4945..3d604c56 100644 --- a/templates/types/streaming/nextjs/app/api/chat/route.ts +++ b/templates/types/streaming/nextjs/app/api/chat/route.ts @@ -1,11 +1,15 @@ import { initObservability } from "@/app/observability"; import { Message, StreamData, StreamingTextResponse } from "ai"; -import { ChatMessage, MessageContent, Settings } from "llamaindex"; +import { ChatMessage, Settings } from "llamaindex"; import { NextRequest, NextResponse } from "next/server"; import { createChatEngine } from "./engine/chat"; import { initSettings } from "./engine/settings"; -import { LlamaIndexStream } from "./llamaindex-stream"; -import { createCallbackManager } from "./stream-helper"; +import { + DataParserOptions, + LlamaIndexStream, + convertMessageContent, +} from "./llamaindex-stream"; +import { createCallbackManager, createStreamTimeout } from "./stream-helper"; initObservability(); initSettings(); @@ -13,29 +17,17 @@ initSettings(); export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -const convertMessageContent = ( - textMessage: string, - imageUrl: string | undefined, -): MessageContent => { - if (!imageUrl) return textMessage; - return [ - { - type: "text", - text: textMessage, - }, - { - type: "image_url", - image_url: { - url: imageUrl, - }, - }, - ]; -}; - export async function POST(request: NextRequest) { + // Init Vercel AI StreamData and timeout + const vercelStreamData = new StreamData(); + const streamTimeout = createStreamTimeout(vercelStreamData); + try { const body = await request.json(); - const { messages, data }: { messages: Message[]; data: any } = body; + const { + messages, + data, + }: { messages: Message[]; data: DataParserOptions | undefined } = body; const userMessage = messages.pop(); if (!messages || !userMessage || userMessage.role !== "user") { return NextResponse.json( @@ -50,13 +42,7 @@ export async function POST(request: NextRequest) { const chatEngine = await createChatEngine(); // Convert message content from Vercel/AI format to LlamaIndex/OpenAI format - const userMessageContent = convertMessageContent( - userMessage.content, - data?.imageUrl, - ); - - // Init Vercel AI StreamData - const vercelStreamData = new StreamData(); + const userMessageContent = convertMessageContent(userMessage.content, data); // Setup callbacks const callbackManager = createCallbackManager(vercelStreamData); @@ -73,7 +59,8 @@ export async function POST(request: NextRequest) { // Transform LlamaIndex stream to Vercel/AI format const stream = LlamaIndexStream(response, vercelStreamData, { parserOptions: { - image_url: data?.imageUrl, + imageUrl: data?.imageUrl, + uploadedCsv: data?.uploadedCsv, }, }); @@ -89,5 +76,7 @@ export async function POST(request: NextRequest) { status: 500, }, ); + } finally { + clearTimeout(streamTimeout); } } diff --git a/templates/types/streaming/nextjs/app/api/chat/stream-helper.ts b/templates/types/streaming/nextjs/app/api/chat/stream-helper.ts index ffc5dfc5..a112d6b7 100644 --- a/templates/types/streaming/nextjs/app/api/chat/stream-helper.ts +++ b/templates/types/streaming/nextjs/app/api/chat/stream-helper.ts @@ -82,6 +82,15 @@ export function appendToolData( }); } +export function createStreamTimeout(stream: StreamData) { + const timeout = Number(process.env.STREAM_TIMEOUT ?? 1000 * 60 * 5); // default to 5 minutes + const t = setTimeout(() => { + appendEventData(stream, `Stream timed out after ${timeout / 1000} seconds`); + stream.close(); + }, timeout); + return t; +} + export function createCallbackManager(stream: StreamData) { const callbackManager = new CallbackManager(); @@ -112,3 +121,17 @@ export function createCallbackManager(stream: StreamData) { return callbackManager; } + +export type UploadedCsv = { + content: string; + filename: string; + filesize: number; +}; + +export function appendCsvData(data: StreamData, uploadedCsv?: UploadedCsv) { + if (!uploadedCsv) return; + data.appendMessageAnnotation({ + type: "csv", + data: uploadedCsv, + }); +} diff --git a/templates/types/streaming/nextjs/app/components/chat-section.tsx b/templates/types/streaming/nextjs/app/components/chat-section.tsx index 4f883220..25c648f4 100644 --- a/templates/types/streaming/nextjs/app/components/chat-section.tsx +++ b/templates/types/streaming/nextjs/app/components/chat-section.tsx @@ -37,7 +37,7 @@ export default function ChatSection() { handleSubmit={handleSubmit} handleInputChange={handleInputChange} isLoading={isLoading} - multiModal={true} + messages={messages} /> </div> ); diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx index 435637e5..28ec04f5 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx @@ -1,8 +1,11 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { CsvData, getInputResources } from "."; 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"; export default function ChatInput( @@ -14,11 +17,21 @@ export default function ChatInput( | "onFileError" | "handleSubmit" | "handleInputChange" - > & { - multiModal?: boolean; - }, + | "messages" + >, ) { 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 onSubmit = (e: React.FormEvent<HTMLFormElement>) => { if (imageUrl) { @@ -28,6 +41,24 @@ 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) { + props.handleSubmit(e, { + data: { uploadedCsv: attachCsv }, + }); + return; + } + props.handleSubmit(e); }; @@ -43,25 +74,62 @@ export default function ChatInput( setImageUrl(base64); }; + const handleUploadCsvFile = async (file: File) => { + const content = await new Promise<string>((resolve, reject) => { + const reader = new FileReader(); + reader.readAsText(file); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + }); + setUploadedCsv({ + content, + filename: file.name, + filesize: file.size, + }); + }; + const handleUploadFile = async (file: File) => { try { - if (props.multiModal && file.type.startsWith("image/")) { + if (file.type.startsWith("image/")) { return await handleUploadImageFile(file); } + if (file.type === "text/csv") { + return await handleUploadCsvFile(file); + } props.onFileUpload?.(file); } catch (error: any) { props.onFileError?.(error.message); } }; + 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)} + /> + )} <div className="flex w-full items-start justify-between gap-4 "> <Input autoFocus diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message.tsx index da1d92e9..fcd56306 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message.tsx @@ -8,14 +8,16 @@ import { ChatEvents } from "./chat-events"; import { ChatImage } from "./chat-image"; import { ChatSources } from "./chat-sources"; import ChatTools from "./chat-tools"; +import CsvContent from "./csv-content"; import { - AnnotationData, + CsvData, EventData, ImageData, MessageAnnotation, MessageAnnotationType, SourceData, ToolData, + getAnnotationData, } from "./index"; import Markdown from "./markdown"; import { useCopyToClipboard } from "./use-copy-to-clipboard"; @@ -25,13 +27,6 @@ type ContentDisplayConfig = { component: JSX.Element | null; }; -function getAnnotationData<T extends AnnotationData>( - annotations: MessageAnnotation[], - type: MessageAnnotationType, -): T[] { - return annotations.filter((a) => a.type === type).map((a) => a.data as T); -} - function ChatMessageContent({ message, isLoading, @@ -46,6 +41,10 @@ function ChatMessageContent({ annotations, MessageAnnotationType.IMAGE, ); + const csvData = getAnnotationData<CsvData>( + annotations, + MessageAnnotationType.CSV, + ); const eventData = getAnnotationData<EventData>( annotations, MessageAnnotationType.EVENTS, @@ -61,16 +60,20 @@ function ChatMessageContent({ const contents: ContentDisplayConfig[] = [ { - order: -3, + order: -4, component: imageData[0] ? <ChatImage data={imageData[0]} /> : null, }, { - order: -2, + order: -3, component: eventData.length > 0 ? ( <ChatEvents isLoading={isLoading} data={eventData} /> ) : null, }, + { + order: -2, + component: csvData[0] ? <CsvContent data={csvData[0]} /> : null, + }, { order: -1, component: toolData[0] ? <ChatTools data={toolData[0]} /> : null, diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-resources.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-resources.tsx new file mode 100644 index 00000000..f708e418 --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-resources.tsx @@ -0,0 +1,48 @@ +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> + ); +} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/csv-content.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/csv-content.tsx new file mode 100644 index 00000000..0cbc7c7d --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/chat/csv-content.tsx @@ -0,0 +1,15 @@ +import { CsvData } from "."; + +const LIMIT_DISPLAY = 100; // Limit the display of CSV content to 100 characters + +export default function CsvContent({ data }: { data: CsvData }) { + const summaryContent = data.content.slice(0, LIMIT_DISPLAY) + "..."; + 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> + ); +} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/index.ts b/templates/types/streaming/nextjs/app/components/ui/chat/index.ts index cb7e9272..fd676dc4 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/index.ts +++ b/templates/types/streaming/nextjs/app/components/ui/chat/index.ts @@ -1,4 +1,4 @@ -import { JSONValue } from "ai"; +import { JSONValue, Message } from "ai"; import ChatInput from "./chat-input"; import ChatMessages from "./chat-messages"; @@ -6,6 +6,7 @@ export { type ChatHandler } from "./chat.interface"; export { ChatInput, ChatMessages }; export enum MessageAnnotationType { + CSV = "csv", IMAGE = "image", SOURCES = "sources", EVENTS = "events", @@ -16,6 +17,12 @@ export type ImageData = { url: string; }; +export type CsvData = { + content: string; + filename: string; + filesize: number; +}; + export type SourceNode = { id: string; metadata: Record<string, unknown>; @@ -47,9 +54,50 @@ export type ToolData = { }; }; -export type AnnotationData = ImageData | SourceData | EventData | ToolData; +export type AnnotationData = + | ImageData + | CsvData + | SourceData + | EventData + | ToolData; export type MessageAnnotation = { type: MessageAnnotationType; data: AnnotationData; }; + +export function getAnnotationData<T extends AnnotationData>( + annotations: MessageAnnotation[], + type: MessageAnnotationType, +): 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, + }; +}; diff --git a/templates/types/streaming/nextjs/app/components/ui/icons/sheet.svg b/templates/types/streaming/nextjs/app/components/ui/icons/sheet.svg new file mode 100644 index 00000000..65f1b0fc --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/icons/sheet.svg @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="49px" height="67px" viewBox="0 0 49 67" version="1.1" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink"> + <title>Sheets-icon</title> + <desc>Created with Sketch.</desc> + <defs> + <path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-1"></path> + <path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-3"></path> + <path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-5"></path> + <linearGradient x1="50.0053945%" y1="8.58610612%" x2="50.0053945%" y2="100.013939%" id="linearGradient-7"> + <stop stop-color="#263238" stop-opacity="0.2" offset="0%"></stop> + <stop stop-color="#263238" stop-opacity="0.02" offset="100%"></stop> + </linearGradient> + <path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-8"></path> + <path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-10"></path> + <path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-12"></path> + <path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-14"></path> + <radialGradient cx="3.16804688%" cy="2.71744318%" fx="3.16804688%" fy="2.71744318%" r="161.248516%" gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)" id="radialGradient-16"> + <stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%"></stop> + <stop stop-color="#FFFFFF" stop-opacity="0" offset="100%"></stop> + </radialGradient> + </defs> + <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Consumer-Apps-Sheets-Large-VD-R8-" transform="translate(-451.000000, -451.000000)"> + <g id="Hero" transform="translate(0.000000, 63.000000)"> + <g id="Personal" transform="translate(277.000000, 299.000000)"> + <g id="Sheets-icon" transform="translate(174.833333, 89.958333)"> + <g id="Group"> + <g id="Clipped"> + <mask id="mask-2" fill="white"> + <use xlink:href="#path-1"></use> + </mask> + <g id="SVGID_1_"></g> + <path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L36.9791667,10.3541667 L29.5833333,0 Z" id="Path" fill="#0F9D58" fill-rule="nonzero" mask="url(#mask-2)"></path> + </g> + <g id="Clipped"> + <mask id="mask-4" fill="white"> + <use xlink:href="#path-3"></use> + </mask> + <g id="SVGID_1_"></g> + <path d="M11.8333333,31.8020833 L11.8333333,53.25 L35.5,53.25 L35.5,31.8020833 L11.8333333,31.8020833 Z M22.1875,50.2916667 L14.7916667,50.2916667 L14.7916667,46.59375 L22.1875,46.59375 L22.1875,50.2916667 Z M22.1875,44.375 L14.7916667,44.375 L14.7916667,40.6770833 L22.1875,40.6770833 L22.1875,44.375 Z M22.1875,38.4583333 L14.7916667,38.4583333 L14.7916667,34.7604167 L22.1875,34.7604167 L22.1875,38.4583333 Z M32.5416667,50.2916667 L25.1458333,50.2916667 L25.1458333,46.59375 L32.5416667,46.59375 L32.5416667,50.2916667 Z M32.5416667,44.375 L25.1458333,44.375 L25.1458333,40.6770833 L32.5416667,40.6770833 L32.5416667,44.375 Z M32.5416667,38.4583333 L25.1458333,38.4583333 L25.1458333,34.7604167 L32.5416667,34.7604167 L32.5416667,38.4583333 Z" id="Shape" fill="#F1F1F1" fill-rule="nonzero" mask="url(#mask-4)"></path> + </g> + <g id="Clipped"> + <mask id="mask-6" fill="white"> + <use xlink:href="#path-5"></use> + </mask> + <g id="SVGID_1_"></g> + <polygon id="Path" fill="url(#linearGradient-7)" fill-rule="nonzero" mask="url(#mask-6)" points="30.8813021 16.4520313 47.3333333 32.9003646 47.3333333 17.75"></polygon> + </g> + <g id="Clipped"> + <mask id="mask-9" fill="white"> + <use xlink:href="#path-8"></use> + </mask> + <g id="SVGID_1_"></g> + <g id="Group" mask="url(#mask-9)"> + <g transform="translate(26.625000, -2.958333)"> + <path d="M2.95833333,2.95833333 L2.95833333,16.2708333 C2.95833333,18.7225521 4.94411458,20.7083333 7.39583333,20.7083333 L20.7083333,20.7083333 L2.95833333,2.95833333 Z" id="Path" fill="#87CEAC" fill-rule="nonzero"></path> + </g> + </g> + </g> + <g id="Clipped"> + <mask id="mask-11" fill="white"> + <use xlink:href="#path-10"></use> + </mask> + <g id="SVGID_1_"></g> + <path d="M4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,4.80729167 C0,2.36666667 1.996875,0.369791667 4.4375,0.369791667 L29.5833333,0.369791667 L29.5833333,0 L4.4375,0 Z" id="Path" fill-opacity="0.2" fill="#FFFFFF" fill-rule="nonzero" mask="url(#mask-11)"></path> + </g> + <g id="Clipped"> + <mask id="mask-13" fill="white"> + <use xlink:href="#path-12"></use> + </mask> + <g id="SVGID_1_"></g> + <path d="M42.8958333,64.7135417 L4.4375,64.7135417 C1.996875,64.7135417 0,62.7166667 0,60.2760417 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,60.2760417 C47.3333333,62.7166667 45.3364583,64.7135417 42.8958333,64.7135417 Z" id="Path" fill-opacity="0.2" fill="#263238" fill-rule="nonzero" mask="url(#mask-13)"></path> + </g> + <g id="Clipped"> + <mask id="mask-15" fill="white"> + <use xlink:href="#path-14"></use> + </mask> + <g id="SVGID_1_"></g> + <path d="M34.0208333,17.75 C31.5691146,17.75 29.5833333,15.7642188 29.5833333,13.3125 L29.5833333,13.6822917 C29.5833333,16.1340104 31.5691146,18.1197917 34.0208333,18.1197917 L47.3333333,18.1197917 L47.3333333,17.75 L34.0208333,17.75 Z" id="Path" fill-opacity="0.1" fill="#263238" fill-rule="nonzero" mask="url(#mask-15)"></path> + </g> + </g> + <path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="Path" fill="url(#radialGradient-16)" fill-rule="nonzero"></path> + </g> + </g> + </g> + </g> + </g> +</svg> \ No newline at end of file diff --git a/templates/types/streaming/nextjs/app/components/ui/upload-csv-preview.tsx b/templates/types/streaming/nextjs/app/components/ui/upload-csv-preview.tsx new file mode 100644 index 00000000..823b2880 --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/upload-csv-preview.tsx @@ -0,0 +1,46 @@ +import { XCircleIcon } from "lucide-react"; +import Image from "next/image"; +import SheetIcon from "../ui/icons/sheet.svg"; +import { cn } from "./lib/utils"; + +export default function UploadCsvPreview({ + filename, + filesize, + onRemove, +}: { + filename: string; + filesize: number; + onRemove: () => void; +}) { + const fileSizeInKB = Math.round((filesize / 1024) * 10) / 10; + return ( + <div className="p-2 w-80 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 + className="h-full w-auto" + priority + src={SheetIcon} + alt="SheetIcon" + /> + </div> + <div className="overflow-hidden"> + <div className="truncate font-semibold"> + {filename} ({fileSizeInKB} KB) + </div> + <div className="truncate text-token-text-tertiary">Spreadsheet</div> + </div> + </div> + <div + className={cn( + "absolute -top-2 -right-2 w-6 h-6 z-10 bg-gray-500 text-white rounded-full", + )} + > + <XCircleIcon + className="w-6 h-6 bg-gray-500 text-white rounded-full" + onClick={onRemove} + /> + </div> + </div> + ); +} -- GitLab