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