From 8a61d7b06af7d4104b48a330936104655c95dc8b Mon Sep 17 00:00:00 2001
From: Marcus Schiesser <mail@marcusschiesser.de>
Date: Mon, 30 Oct 2023 15:23:32 +0700
Subject: [PATCH] unified streaming and non-streaming

---
 .../nextjs/app/api/{llm => chat}/route.ts     | 16 ++--
 .../nextjs/app/components/chat-item.tsx       | 13 ---
 .../nextjs/app/components/chat-section.tsx    | 87 +++++++++++--------
 .../nextjs/app/components/message-form.tsx    | 83 ------------------
 .../app/components/{ => ui}/chat-avatar.tsx   |  6 +-
 .../nextjs/app/components/ui/chat-input.tsx   | 42 +++++++++
 .../nextjs/app/components/ui/chat-item.tsx    | 13 +++
 .../chat-messages.tsx}                        | 18 ++--
 .../app/services/chatStorage.service.ts       | 35 --------
 templates/simple/nextjs/package.json          | 13 +--
 .../nextjs/app/components/chat-section.tsx    |  4 +-
 .../nextjs/app/components/ui/chat-avatar.tsx  |  6 +-
 .../nextjs/app/components/ui/chat-input.tsx   | 17 +++-
 .../nextjs/app/components/ui/chat-item.tsx    | 10 +--
 .../app/components/ui/chat-messages.tsx       |  9 +-
 15 files changed, 167 insertions(+), 205 deletions(-)
 rename templates/simple/nextjs/app/api/{llm => chat}/route.ts (70%)
 delete mode 100644 templates/simple/nextjs/app/components/chat-item.tsx
 delete mode 100644 templates/simple/nextjs/app/components/message-form.tsx
 rename templates/simple/nextjs/app/components/{ => ui}/chat-avatar.tsx (87%)
 create mode 100644 templates/simple/nextjs/app/components/ui/chat-input.tsx
 create mode 100644 templates/simple/nextjs/app/components/ui/chat-item.tsx
 rename templates/simple/nextjs/app/components/{chat-history.tsx => ui/chat-messages.tsx} (66%)
 delete mode 100644 templates/simple/nextjs/app/services/chatStorage.service.ts

diff --git a/templates/simple/nextjs/app/api/llm/route.ts b/templates/simple/nextjs/app/api/chat/route.ts
similarity index 70%
rename from templates/simple/nextjs/app/api/llm/route.ts
rename to templates/simple/nextjs/app/api/chat/route.ts
index b399143b..8647e704 100644
--- a/templates/simple/nextjs/app/api/llm/route.ts
+++ b/templates/simple/nextjs/app/api/chat/route.ts
@@ -7,17 +7,13 @@ export const dynamic = "force-dynamic";
 export async function POST(request: NextRequest) {
   try {
     const body = await request.json();
-    const {
-      message,
-      chatHistory,
-    }: {
-      message: string;
-      chatHistory: ChatMessage[];
-    } = body;
-    if (!message || !chatHistory) {
+    const { messages }: { messages: ChatMessage[] } = body;
+    const lastMessage = messages.pop();
+    if (!messages || !lastMessage || lastMessage.role !== "user") {
       return NextResponse.json(
         {
-          error: "message, chatHistory are required in the request body",
+          error:
+            "messages are required in the request body and the last message must be from the user",
         },
         { status: 400 },
       );
@@ -31,7 +27,7 @@ export async function POST(request: NextRequest) {
       llm,
     });
 
-    const response = await chatEngine.chat(message, chatHistory);
+    const response = await chatEngine.chat(lastMessage.content, messages);
     const result: ChatMessage = {
       role: "assistant",
       content: response.response,
diff --git a/templates/simple/nextjs/app/components/chat-item.tsx b/templates/simple/nextjs/app/components/chat-item.tsx
deleted file mode 100644
index 5d32c64e..00000000
--- a/templates/simple/nextjs/app/components/chat-item.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-"use client";
-
-import ChatAvatar from "@/app/components/chat-avatar";
-import { ChatMessage } from "llamaindex";
-
-export default function ChatItem(chatMessage: ChatMessage) {
-  return (
-    <div className="flex items-start gap-4 pt-5">
-      <ChatAvatar {...chatMessage} />
-      <p className="break-words">{chatMessage.content}</p>
-    </div>
-  );
-}
diff --git a/templates/simple/nextjs/app/components/chat-section.tsx b/templates/simple/nextjs/app/components/chat-section.tsx
index 416c56cd..989bfcda 100644
--- a/templates/simple/nextjs/app/components/chat-section.tsx
+++ b/templates/simple/nextjs/app/components/chat-section.tsx
@@ -1,45 +1,62 @@
 "use client";
 
-import ChatHistory from "@/app/components/chat-history";
-import MessageForm from "@/app/components/message-form";
-import ChatStorageService from "@/app/services/chatStorage.service";
-import { ChatMessage } from "llamaindex";
-import { createContext, useContext, useEffect, useState } from "react";
+import { nanoid } from "nanoid";
+import { useState } from "react";
+import ChatInput from "./ui/chat-input";
+import ChatMessages, { Message } from "./ui/chat-messages";
 
-const ChatSectionContext = createContext<{
-  chatHistory: ChatMessage[];
-  loadChat: () => void;
-}>({
-  chatHistory: [],
-  loadChat: () => {},
-});
-
-const ChatSectionContextProvider = (props: { children: JSX.Element[] }) => {
-  const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
-
-  const loadChat = () => {
-    const data = ChatStorageService.getChatHistory();
-    setChatHistory(data);
+export default function ChatSection() {
+  const [messages, setMessages] = useState<Message[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [input, setInput] = useState("");
+
+  const getAssistantMessage = async (messages: Message[]) => {
+    const response = await fetch("/api/chat", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({
+        messages,
+      }),
+    });
+    const data = await response.json();
+    const assistantMessage = data.result as Message;
+    return assistantMessage;
   };
 
-  useEffect(() => {
-    loadChat();
-  }, []);
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    if (!input) return;
+    try {
+      setLoading(true);
+      const newMessages = [
+        ...messages,
+        { id: nanoid(), content: input, role: "user" },
+      ];
+      setMessages(newMessages);
+      setInput("");
+      const assistantMessage = await getAssistantMessage(newMessages);
+      setMessages([...newMessages, { ...assistantMessage }]);
+      setLoading(false);
+    } catch (error: any) {
+      alert(JSON.stringify(error));
+    }
+  };
 
-  return (
-    <ChatSectionContext.Provider value={{ chatHistory, loadChat }}>
-      {props.children}
-    </ChatSectionContext.Provider>
-  );
-};
+  const handleInputChange = (e: any): void => {
+    setInput(e.target.value);
+  };
 
-export default function ChatSection() {
   return (
-    <ChatSectionContextProvider>
-      <ChatHistory />
-      <MessageForm />
-    </ChatSectionContextProvider>
+    <>
+      <ChatMessages messages={messages} />
+      <ChatInput
+        handleSubmit={handleSubmit}
+        isLoading={loading}
+        input={input}
+        handleInputChange={handleInputChange}
+      />
+    </>
   );
 }
-
-export const useChat = () => useContext(ChatSectionContext);
diff --git a/templates/simple/nextjs/app/components/message-form.tsx b/templates/simple/nextjs/app/components/message-form.tsx
deleted file mode 100644
index fff39b9a..00000000
--- a/templates/simple/nextjs/app/components/message-form.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-"use client";
-import { useChat } from "@/app/components/chat-section";
-import ChatStorageService from "@/app/services/chatStorage.service";
-import { ChatMessage } from "llamaindex";
-import { useState } from "react";
-
-const LLM_API_ROUTE = "/api/llm";
-
-export default function MessageForm() {
-  const { loadChat } = useChat();
-  const [loading, setLoading] = useState(false);
-
-  const getAssistantMessage = async (message: string) => {
-    const response = await fetch(LLM_API_ROUTE, {
-      method: "POST",
-      headers: {
-        "Content-Type": "application/json",
-      },
-      body: JSON.stringify({
-        message,
-        chatHistory: ChatStorageService.getChatHistory(),
-      }),
-    });
-    const data = await response.json();
-    const assistantMessage = data.result as ChatMessage;
-    return assistantMessage;
-  };
-
-  const sendMessage = async (message: string) => {
-    if (!message) return;
-    try {
-      setLoading(true);
-      const userMessage: ChatMessage = { content: message, role: "user" };
-      ChatStorageService.addChatMessage(userMessage);
-      loadChat();
-      const assistantMessage = await getAssistantMessage(message);
-      ChatStorageService.addChatMessage(assistantMessage);
-      setLoading(false);
-      loadChat();
-    } catch (error: any) {
-      alert(JSON.stringify(error));
-    }
-  };
-
-  const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    const message = e.currentTarget.message.value;
-    await sendMessage(message);
-  };
-
-  const handleKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
-    if (e.key === "Enter" && !e.shiftKey) {
-      e.preventDefault();
-      const message = e.currentTarget.value;
-      if (message !== "") {
-        await sendMessage(message);
-      }
-    }
-  };
-
-  return (
-    <form
-      onSubmit={handleFormSubmit}
-      className="flex items-start justify-between w-full max-w-5xl p-4 bg-white rounded-xl shadow-xl gap-4"
-    >
-      <textarea
-        rows={1}
-        autoFocus
-        name="message"
-        placeholder="Type a message"
-        className="w-full p-4 rounded-xl shadow-inner flex-1"
-        onKeyDown={handleKeyDown}
-      />
-      <button
-        disabled={loading}
-        type="submit"
-        className="p-4 text-white rounded-xl shadow-xl bg-gradient-to-r from-cyan-500 to-sky-500 disabled:opacity-50 disabled:cursor-not-allowed"
-      >
-        Send message
-      </button>
-    </form>
-  );
-}
diff --git a/templates/simple/nextjs/app/components/chat-avatar.tsx b/templates/simple/nextjs/app/components/ui/chat-avatar.tsx
similarity index 87%
rename from templates/simple/nextjs/app/components/chat-avatar.tsx
rename to templates/simple/nextjs/app/components/ui/chat-avatar.tsx
index 2f79edae..cd241104 100644
--- a/templates/simple/nextjs/app/components/chat-avatar.tsx
+++ b/templates/simple/nextjs/app/components/ui/chat-avatar.tsx
@@ -1,10 +1,10 @@
 "use client";
 
-import { ChatMessage } from "llamaindex";
 import Image from "next/image";
+import { Message } from "./chat-messages";
 
-export default function ChatAvatar(chatMessage: ChatMessage) {
-  if (chatMessage.role === "user") {
+export default function ChatAvatar(message: Message) {
+  if (message.role === "user") {
     return (
       <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background">
         <svg
diff --git a/templates/simple/nextjs/app/components/ui/chat-input.tsx b/templates/simple/nextjs/app/components/ui/chat-input.tsx
new file mode 100644
index 00000000..3eb979b0
--- /dev/null
+++ b/templates/simple/nextjs/app/components/ui/chat-input.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+export interface ChatInputProps {
+  /** The current value of the input */
+  input?: string;
+  /** An input/textarea-ready onChange handler to control the value of the input */
+  handleInputChange?: (
+    e:
+      | React.ChangeEvent<HTMLInputElement>
+      | React.ChangeEvent<HTMLTextAreaElement>,
+  ) => void;
+  /** Form submission handler to automatically reset input and append a user message  */
+  handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
+  isLoading: boolean;
+}
+
+export default function ChatInput(props: ChatInputProps) {
+  return (
+    <>
+      <form
+        onSubmit={props.handleSubmit}
+        className="flex items-start justify-between w-full max-w-5xl p-4 bg-white rounded-xl shadow-xl gap-4"
+      >
+        <input
+          autoFocus
+          name="message"
+          placeholder="Type a message"
+          className="w-full p-4 rounded-xl shadow-inner flex-1"
+          value={props.input}
+          onChange={props.handleInputChange}
+        />
+        <button
+          disabled={props.isLoading}
+          type="submit"
+          className="p-4 text-white rounded-xl shadow-xl bg-gradient-to-r from-cyan-500 to-sky-500 disabled:opacity-50 disabled:cursor-not-allowed"
+        >
+          Send message
+        </button>
+      </form>
+    </>
+  );
+}
diff --git a/templates/simple/nextjs/app/components/ui/chat-item.tsx b/templates/simple/nextjs/app/components/ui/chat-item.tsx
new file mode 100644
index 00000000..2244f729
--- /dev/null
+++ b/templates/simple/nextjs/app/components/ui/chat-item.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import ChatAvatar from "./chat-avatar";
+import { Message } from "./chat-messages";
+
+export default function ChatItem(message: Message) {
+  return (
+    <div className="flex items-start gap-4 pt-5">
+      <ChatAvatar {...message} />
+      <p className="break-words">{message.content}</p>
+    </div>
+  );
+}
diff --git a/templates/simple/nextjs/app/components/chat-history.tsx b/templates/simple/nextjs/app/components/ui/chat-messages.tsx
similarity index 66%
rename from templates/simple/nextjs/app/components/chat-history.tsx
rename to templates/simple/nextjs/app/components/ui/chat-messages.tsx
index 2eafff4c..65eacabb 100644
--- a/templates/simple/nextjs/app/components/chat-history.tsx
+++ b/templates/simple/nextjs/app/components/ui/chat-messages.tsx
@@ -1,12 +1,16 @@
 "use client";
 
-import ChatItem from "@/app/components/chat-item";
-import { useChat } from "@/app/components/chat-section";
 import { useEffect, useRef } from "react";
+import ChatItem from "./chat-item";
 
-export default function ChatHistory() {
+export interface Message {
+  id: string;
+  content: string;
+  role: string;
+}
+
+export default function ChatMessages({ messages }: { messages: Message[] }) {
   const scrollableChatContainerRef = useRef<HTMLDivElement>(null);
-  const { chatHistory } = useChat();
 
   const scrollToBottom = () => {
     if (scrollableChatContainerRef.current) {
@@ -17,7 +21,7 @@ export default function ChatHistory() {
 
   useEffect(() => {
     scrollToBottom();
-  }, [chatHistory.length]);
+  }, [messages.length]);
 
   return (
     <div className="w-full max-w-5xl p-4 bg-white rounded-xl shadow-xl">
@@ -25,8 +29,8 @@ export default function ChatHistory() {
         className="flex flex-col gap-5 divide-y h-[50vh] overflow-auto"
         ref={scrollableChatContainerRef}
       >
-        {chatHistory.map((chatMessage, index) => (
-          <ChatItem key={index} {...chatMessage} />
+        {messages.map((m: Message) => (
+          <ChatItem key={m.id} {...m} />
         ))}
       </div>
     </div>
diff --git a/templates/simple/nextjs/app/services/chatStorage.service.ts b/templates/simple/nextjs/app/services/chatStorage.service.ts
deleted file mode 100644
index 6f92ce86..00000000
--- a/templates/simple/nextjs/app/services/chatStorage.service.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { ChatMessage } from "llamaindex";
-
-export const CHAT_STORAGE_KEY = "chatHistory";
-
-class _ChatStorageService {
-  public getChatHistory(): ChatMessage[] {
-    const chatHistory = localStorage.getItem(CHAT_STORAGE_KEY);
-    return chatHistory ? JSON.parse(chatHistory) : [];
-  }
-
-  public saveChatHistory(chatHistory: ChatMessage[]) {
-    localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(chatHistory));
-  }
-
-  public clearChatHistory() {
-    localStorage.removeItem(CHAT_STORAGE_KEY);
-  }
-
-  public addChatMessage(chatMessage: ChatMessage) {
-    const chatHistory = this.getChatHistory();
-    chatHistory.push(chatMessage);
-    this.saveChatHistory(chatHistory);
-  }
-
-  constructor() {
-    if (typeof window !== "undefined") {
-      const chatHistory = localStorage.getItem(CHAT_STORAGE_KEY);
-      if (!chatHistory) this.saveChatHistory([]);
-    }
-  }
-}
-
-const ChatStorageService = new _ChatStorageService();
-
-export default ChatStorageService;
diff --git a/templates/simple/nextjs/package.json b/templates/simple/nextjs/package.json
index afb59904..990b41c8 100644
--- a/templates/simple/nextjs/package.json
+++ b/templates/simple/nextjs/package.json
@@ -8,20 +8,21 @@
     "lint": "next lint"
   },
   "dependencies": {
-    "react": "^18",
-    "react-dom": "^18",
+    "llamaindex": "0.0.31",
+    "nanoid": "^5",
     "next": "^13",
-    "llamaindex": "0.0.31"
+    "react": "^18",
+    "react-dom": "^18"
   },
   "devDependencies": {
-    "typescript": "^5",
     "@types/node": "^20",
     "@types/react": "^18",
     "@types/react-dom": "^18",
     "autoprefixer": "^10",
+    "eslint": "^8",
+    "eslint-config-next": "^13",
     "postcss": "^8",
     "tailwindcss": "^3",
-    "eslint": "^8",
-    "eslint-config-next": "^13"
+    "typescript": "^5"
   }
 }
\ No newline at end of file
diff --git a/templates/streaming/nextjs/app/components/chat-section.tsx b/templates/streaming/nextjs/app/components/chat-section.tsx
index a48e0214..5ef09b33 100644
--- a/templates/streaming/nextjs/app/components/chat-section.tsx
+++ b/templates/streaming/nextjs/app/components/chat-section.tsx
@@ -5,7 +5,8 @@ import { useChat } from "ai/react";
 import ChatMessages from "./ui/chat-messages";
 
 export default function ChatSection() {
-  const { messages, input, handleSubmit, handleInputChange } = useChat();
+  const { messages, input, isLoading, handleSubmit, handleInputChange } =
+    useChat();
 
   return (
     <>
@@ -14,6 +15,7 @@ export default function ChatSection() {
         input={input}
         handleSubmit={handleSubmit}
         handleInputChange={handleInputChange}
+        isLoading={isLoading}
       />
     </>
   );
diff --git a/templates/streaming/nextjs/app/components/ui/chat-avatar.tsx b/templates/streaming/nextjs/app/components/ui/chat-avatar.tsx
index 14c7b06f..cd241104 100644
--- a/templates/streaming/nextjs/app/components/ui/chat-avatar.tsx
+++ b/templates/streaming/nextjs/app/components/ui/chat-avatar.tsx
@@ -1,10 +1,10 @@
 "use client";
 
-import { Message } from "ai/react";
 import Image from "next/image";
+import { Message } from "./chat-messages";
 
-export default function ChatAvatar(chatMessage: Message) {
-  if (chatMessage.role === "user") {
+export default function ChatAvatar(message: Message) {
+  if (message.role === "user") {
     return (
       <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background">
         <svg
diff --git a/templates/streaming/nextjs/app/components/ui/chat-input.tsx b/templates/streaming/nextjs/app/components/ui/chat-input.tsx
index 73ccb169..3eb979b0 100644
--- a/templates/streaming/nextjs/app/components/ui/chat-input.tsx
+++ b/templates/streaming/nextjs/app/components/ui/chat-input.tsx
@@ -1,8 +1,20 @@
 "use client";
 
-import { UseChatHelpers } from "ai/react";
+export interface ChatInputProps {
+  /** The current value of the input */
+  input?: string;
+  /** An input/textarea-ready onChange handler to control the value of the input */
+  handleInputChange?: (
+    e:
+      | React.ChangeEvent<HTMLInputElement>
+      | React.ChangeEvent<HTMLTextAreaElement>,
+  ) => void;
+  /** Form submission handler to automatically reset input and append a user message  */
+  handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
+  isLoading: boolean;
+}
 
-export default function ChatInput(props: Partial<UseChatHelpers>) {
+export default function ChatInput(props: ChatInputProps) {
   return (
     <>
       <form
@@ -18,6 +30,7 @@ export default function ChatInput(props: Partial<UseChatHelpers>) {
           onChange={props.handleInputChange}
         />
         <button
+          disabled={props.isLoading}
           type="submit"
           className="p-4 text-white rounded-xl shadow-xl bg-gradient-to-r from-cyan-500 to-sky-500 disabled:opacity-50 disabled:cursor-not-allowed"
         >
diff --git a/templates/streaming/nextjs/app/components/ui/chat-item.tsx b/templates/streaming/nextjs/app/components/ui/chat-item.tsx
index cb962611..2244f729 100644
--- a/templates/streaming/nextjs/app/components/ui/chat-item.tsx
+++ b/templates/streaming/nextjs/app/components/ui/chat-item.tsx
@@ -1,13 +1,13 @@
 "use client";
 
-import ChatAvatar from "@/app/components/ui/chat-avatar";
-import { Message } from "ai/react";
+import ChatAvatar from "./chat-avatar";
+import { Message } from "./chat-messages";
 
-export default function ChatItem(chatMessage: Message) {
+export default function ChatItem(message: Message) {
   return (
     <div className="flex items-start gap-4 pt-5">
-      <ChatAvatar {...chatMessage} />
-      <p className="break-words">{chatMessage.content}</p>
+      <ChatAvatar {...message} />
+      <p className="break-words">{message.content}</p>
     </div>
   );
 }
diff --git a/templates/streaming/nextjs/app/components/ui/chat-messages.tsx b/templates/streaming/nextjs/app/components/ui/chat-messages.tsx
index e039ebf6..65eacabb 100644
--- a/templates/streaming/nextjs/app/components/ui/chat-messages.tsx
+++ b/templates/streaming/nextjs/app/components/ui/chat-messages.tsx
@@ -1,8 +1,13 @@
 "use client";
 
-import ChatItem from "@/app/components/ui/chat-item";
-import { Message } from "ai/react";
 import { useEffect, useRef } from "react";
+import ChatItem from "./chat-item";
+
+export interface Message {
+  id: string;
+  content: string;
+  role: string;
+}
 
 export default function ChatMessages({ messages }: { messages: Message[] }) {
   const scrollableChatContainerRef = useRef<HTMLDivElement>(null);
-- 
GitLab