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