Skip to content
Snippets Groups Projects
Commit 8a61d7b0 authored by Marcus Schiesser's avatar Marcus Schiesser
Browse files

unified streaming and non-streaming

parent b55be098
No related branches found
No related tags found
No related merge requests found
Showing
with 167 additions and 205 deletions
...@@ -7,17 +7,13 @@ export const dynamic = "force-dynamic"; ...@@ -7,17 +7,13 @@ export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { const { messages }: { messages: ChatMessage[] } = body;
message, const lastMessage = messages.pop();
chatHistory, if (!messages || !lastMessage || lastMessage.role !== "user") {
}: {
message: string;
chatHistory: ChatMessage[];
} = body;
if (!message || !chatHistory) {
return NextResponse.json( 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 }, { status: 400 },
); );
...@@ -31,7 +27,7 @@ export async function POST(request: NextRequest) { ...@@ -31,7 +27,7 @@ export async function POST(request: NextRequest) {
llm, llm,
}); });
const response = await chatEngine.chat(message, chatHistory); const response = await chatEngine.chat(lastMessage.content, messages);
const result: ChatMessage = { const result: ChatMessage = {
role: "assistant", role: "assistant",
content: response.response, content: response.response,
......
"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>
);
}
"use client"; "use client";
import ChatHistory from "@/app/components/chat-history"; import { nanoid } from "nanoid";
import MessageForm from "@/app/components/message-form"; import { useState } from "react";
import ChatStorageService from "@/app/services/chatStorage.service"; import ChatInput from "./ui/chat-input";
import { ChatMessage } from "llamaindex"; import ChatMessages, { Message } from "./ui/chat-messages";
import { createContext, useContext, useEffect, useState } from "react";
const ChatSectionContext = createContext<{ export default function ChatSection() {
chatHistory: ChatMessage[]; const [messages, setMessages] = useState<Message[]>([]);
loadChat: () => void; const [loading, setLoading] = useState(false);
}>({ const [input, setInput] = useState("");
chatHistory: [],
loadChat: () => {}, const getAssistantMessage = async (messages: Message[]) => {
}); const response = await fetch("/api/chat", {
method: "POST",
const ChatSectionContextProvider = (props: { children: JSX.Element[] }) => { headers: {
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]); "Content-Type": "application/json",
},
const loadChat = () => { body: JSON.stringify({
const data = ChatStorageService.getChatHistory(); messages,
setChatHistory(data); }),
});
const data = await response.json();
const assistantMessage = data.result as Message;
return assistantMessage;
}; };
useEffect(() => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
loadChat(); 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 ( const handleInputChange = (e: any): void => {
<ChatSectionContext.Provider value={{ chatHistory, loadChat }}> setInput(e.target.value);
{props.children} };
</ChatSectionContext.Provider>
);
};
export default function ChatSection() {
return ( return (
<ChatSectionContextProvider> <>
<ChatHistory /> <ChatMessages messages={messages} />
<MessageForm /> <ChatInput
</ChatSectionContextProvider> handleSubmit={handleSubmit}
isLoading={loading}
input={input}
handleInputChange={handleInputChange}
/>
</>
); );
} }
export const useChat = () => useContext(ChatSectionContext);
"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>
);
}
"use client"; "use client";
import { ChatMessage } from "llamaindex";
import Image from "next/image"; import Image from "next/image";
import { Message } from "./chat-messages";
export default function ChatAvatar(chatMessage: ChatMessage) { export default function ChatAvatar(message: Message) {
if (chatMessage.role === "user") { if (message.role === "user") {
return ( return (
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background"> <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background">
<svg <svg
......
"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>
</>
);
}
"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>
);
}
"use client"; "use client";
import ChatItem from "@/app/components/chat-item";
import { useChat } from "@/app/components/chat-section";
import { useEffect, useRef } from "react"; 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 scrollableChatContainerRef = useRef<HTMLDivElement>(null);
const { chatHistory } = useChat();
const scrollToBottom = () => { const scrollToBottom = () => {
if (scrollableChatContainerRef.current) { if (scrollableChatContainerRef.current) {
...@@ -17,7 +21,7 @@ export default function ChatHistory() { ...@@ -17,7 +21,7 @@ export default function ChatHistory() {
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [chatHistory.length]); }, [messages.length]);
return ( return (
<div className="w-full max-w-5xl p-4 bg-white rounded-xl shadow-xl"> <div className="w-full max-w-5xl p-4 bg-white rounded-xl shadow-xl">
...@@ -25,8 +29,8 @@ export default function ChatHistory() { ...@@ -25,8 +29,8 @@ export default function ChatHistory() {
className="flex flex-col gap-5 divide-y h-[50vh] overflow-auto" className="flex flex-col gap-5 divide-y h-[50vh] overflow-auto"
ref={scrollableChatContainerRef} ref={scrollableChatContainerRef}
> >
{chatHistory.map((chatMessage, index) => ( {messages.map((m: Message) => (
<ChatItem key={index} {...chatMessage} /> <ChatItem key={m.id} {...m} />
))} ))}
</div> </div>
</div> </div>
......
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;
...@@ -8,20 +8,21 @@ ...@@ -8,20 +8,21 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"react": "^18", "llamaindex": "0.0.31",
"react-dom": "^18", "nanoid": "^5",
"next": "^13", "next": "^13",
"llamaindex": "0.0.31" "react": "^18",
"react-dom": "^18"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"autoprefixer": "^10", "autoprefixer": "^10",
"eslint": "^8",
"eslint-config-next": "^13",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3", "tailwindcss": "^3",
"eslint": "^8", "typescript": "^5"
"eslint-config-next": "^13"
} }
} }
\ No newline at end of file
...@@ -5,7 +5,8 @@ import { useChat } from "ai/react"; ...@@ -5,7 +5,8 @@ import { useChat } from "ai/react";
import ChatMessages from "./ui/chat-messages"; import ChatMessages from "./ui/chat-messages";
export default function ChatSection() { export default function ChatSection() {
const { messages, input, handleSubmit, handleInputChange } = useChat(); const { messages, input, isLoading, handleSubmit, handleInputChange } =
useChat();
return ( return (
<> <>
...@@ -14,6 +15,7 @@ export default function ChatSection() { ...@@ -14,6 +15,7 @@ export default function ChatSection() {
input={input} input={input}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
handleInputChange={handleInputChange} handleInputChange={handleInputChange}
isLoading={isLoading}
/> />
</> </>
); );
......
"use client"; "use client";
import { Message } from "ai/react";
import Image from "next/image"; import Image from "next/image";
import { Message } from "./chat-messages";
export default function ChatAvatar(chatMessage: Message) { export default function ChatAvatar(message: Message) {
if (chatMessage.role === "user") { if (message.role === "user") {
return ( return (
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background"> <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background">
<svg <svg
......
"use client"; "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 ( return (
<> <>
<form <form
...@@ -18,6 +30,7 @@ export default function ChatInput(props: Partial<UseChatHelpers>) { ...@@ -18,6 +30,7 @@ export default function ChatInput(props: Partial<UseChatHelpers>) {
onChange={props.handleInputChange} onChange={props.handleInputChange}
/> />
<button <button
disabled={props.isLoading}
type="submit" 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" 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"
> >
......
"use client"; "use client";
import ChatAvatar from "@/app/components/ui/chat-avatar"; import ChatAvatar from "./chat-avatar";
import { Message } from "ai/react"; import { Message } from "./chat-messages";
export default function ChatItem(chatMessage: Message) { export default function ChatItem(message: Message) {
return ( return (
<div className="flex items-start gap-4 pt-5"> <div className="flex items-start gap-4 pt-5">
<ChatAvatar {...chatMessage} /> <ChatAvatar {...message} />
<p className="break-words">{chatMessage.content}</p> <p className="break-words">{message.content}</p>
</div> </div>
); );
} }
"use client"; "use client";
import ChatItem from "@/app/components/ui/chat-item";
import { Message } from "ai/react";
import { useEffect, useRef } from "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[] }) { export default function ChatMessages({ messages }: { messages: Message[] }) {
const scrollableChatContainerRef = useRef<HTMLDivElement>(null); const scrollableChatContainerRef = useRef<HTMLDivElement>(null);
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment