feat: update message status handler

This commit is contained in:
Steven
2023-04-01 11:32:39 +08:00
parent 558f75f9a4
commit 06eda19fa3
5 changed files with 117 additions and 98 deletions

View File

@ -56,7 +56,7 @@ const MessageTextarea = (props: Props) => {
creatorRole: CreatorRole.User, creatorRole: CreatorRole.User,
createdAt: Date.now(), createdAt: Date.now(),
content: value, content: value,
isGenerated: true, status: "DONE",
}); });
setValue(""); setValue("");
textareaRef.current!.value = ""; textareaRef.current!.value = "";

View File

@ -1,6 +1,7 @@
import { Menu, MenuItem } from "@mui/material"; import { Menu, MenuItem } from "@mui/material";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ReactElement, useState } from "react"; import { ReactElement, useState } from "react";
import { ThreeDots } from "react-loader-spinner";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@ -68,61 +69,71 @@ const MessageView = (props: Props) => {
<img className="w-10 h-auto p-1" src="/chat-logo-bot.webp" alt="" /> <img className="w-10 h-auto p-1" src="/chat-logo-bot.webp" alt="" />
)} )}
</div> </div>
<div className="w-auto max-w-[calc(100%-4rem)] flex flex-col justify-start items-start"> {message.status === "LOADING" && message.content === "" ? (
<ReactMarkdown <div className="mt-0.5 w-12 bg-gray-100 px-4 py-2 rounded-lg">
className="w-auto max-w-full bg-gray-100 px-4 py-2 rounded-lg prose prose-neutral" <ThreeDots wrapperClass="opacity-80" width="24" height="24" color="" />
remarkPlugins={[remarkGfm]} </div>
components={{ ) : (
pre({ node, className, children, ...props }) { <>
const child = children[0] as ReactElement; <div className="w-auto max-w-[calc(100%-4rem)] flex flex-col justify-start items-start">
const match = /language-(\w+)/.exec(child.props.className || ""); <ReactMarkdown
const language = match ? match[1] : "text"; className={`w-auto max-w-full bg-gray-100 px-4 py-2 rounded-lg prose prose-neutral ${
return ( message.status === "FAILED" && "border border-red-400 bg-red-100 text-red-500"
<pre className={`${className || ""} w-full p-0 my-1`} {...props}> }`}
<CodeBlock remarkPlugins={[remarkGfm]}
key={Math.random()} components={{
language={language || "SQL"} pre({ node, className, children, ...props }) {
value={String(child.props.children).replace(/\n$/, "")} const child = children[0] as ReactElement;
{...props} const match = /language-(\w+)/.exec(child.props.className || "");
/> const language = match ? match[1] : "text";
</pre> return (
); <pre className={`${className || ""} w-full p-0 my-1`} {...props}>
}, <CodeBlock
code({ children }) { key={Math.random()}
return <code className="px-0">`{children}`</code>; language={language || "SQL"}
}, value={String(child.props.children).replace(/\n$/, "")}
}} {...props}
> />
{message.content} </pre>
</ReactMarkdown> );
<span className="self-end text-sm text-gray-400 pt-1 pr-1">{dayjs(message.createdAt).format("lll")}</span> },
</div> code({ children }) {
<div className={`invisible group-hover:visible ${showMenu && "!visible"}`}> return <code className="px-0">`{children}`</code>;
<button },
className="w-6 h-6 ml-1 mt-2 flex justify-center items-center text-gray-400 hover:text-gray-500" }}
onClick={handleMoreMenuClick} >
> {message.content}
<Icon.IoMdMore className="w-5 h-auto" /> </ReactMarkdown>
</button> <span className="self-end text-sm text-gray-400 pt-1 pr-1">{dayjs(message.createdAt).format("lll")}</span>
<Menu </div>
className="mt-1" <div className={`invisible group-hover:visible ${showMenu && "!visible"}`}>
anchorEl={menuAnchorEl} <button
open={showMenu} className="w-6 h-6 ml-1 mt-2 flex justify-center items-center text-gray-400 hover:text-gray-500"
onClose={() => setMenuAnchorEl(null)} onClick={handleMoreMenuClick}
MenuListProps={{ >
"aria-labelledby": "basic-button", <Icon.IoMdMore className="w-5 h-auto" />
}} </button>
> <Menu
<MenuItem onClick={copyMessage}> className="mt-1"
<Icon.BiClipboard className="w-4 h-auto mr-2 opacity-70" /> anchorEl={menuAnchorEl}
Copy open={showMenu}
</MenuItem> onClose={() => setMenuAnchorEl(null)}
<MenuItem onClick={() => deleteMessage(message)}> MenuListProps={{
<Icon.BiTrash className="w-4 h-auto mr-2 opacity-70" /> "aria-labelledby": "basic-button",
Delete }}
</MenuItem> >
</Menu> <MenuItem onClick={copyMessage}>
</div> <Icon.BiClipboard className="w-4 h-auto mr-2 opacity-70" />
Copy
</MenuItem>
<MenuItem onClick={() => deleteMessage(message)}>
<Icon.BiTrash className="w-4 h-auto mr-2 opacity-70" />
Delete
</MenuItem>
</Menu>
</div>
</>
)}
</> </>
)} )}
</div> </div>

View File

@ -8,7 +8,6 @@ import Header from "./Header";
import EmptyView from "../EmptyView"; import EmptyView from "../EmptyView";
import MessageView from "./MessageView"; import MessageView from "./MessageView";
import MessageTextarea from "./MessageTextarea"; import MessageTextarea from "./MessageTextarea";
import MessageLoader from "../MessageLoader";
import DataStorageBanner from "../DataStorageBanner"; import DataStorageBanner from "../DataStorageBanner";
// The maximum number of tokens that can be sent to the OpenAI API. // The maximum number of tokens that can be sent to the OpenAI API.
@ -19,17 +18,29 @@ const ChatView = () => {
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
const chatStore = useChatStore(); const chatStore = useChatStore();
const messageStore = useMessageStore(); const messageStore = useMessageStore();
const [isRequesting, setIsRequesting] = useState<boolean>(false); const [isStickyAtBottom, setIsStickyAtBottom] = useState<boolean>(true);
const [showHeaderShadow, setShowHeaderShadow] = useState<boolean>(false); const [showHeaderShadow, setShowHeaderShadow] = useState<boolean>(false);
const chatViewRef = useRef<HTMLDivElement>(null); const chatViewRef = useRef<HTMLDivElement>(null);
const currentChat = chatStore.currentChat; const currentChat = chatStore.currentChat;
const messageList = messageStore.messageList.filter((message) => message.chatId === currentChat?.id); const messageList = messageStore.messageList.filter((message) => message.chatId === currentChat?.id);
const lastMessage = last(messageList); const lastMessage = last(messageList);
// Toggle header shadow.
useEffect(() => { useEffect(() => {
messageStore.messageList.map((message) => {
if (message.status === "LOADING" && message.content === "") {
messageStore.updateMessage(message.id, {
content: "Failed to send the message.",
status: "FAILED",
});
}
});
const handleChatViewScroll = () => { const handleChatViewScroll = () => {
if (!chatViewRef.current) {
return;
}
setShowHeaderShadow((chatViewRef.current?.scrollTop || 0) > 0); setShowHeaderShadow((chatViewRef.current?.scrollTop || 0) > 0);
setIsStickyAtBottom(chatViewRef.current.scrollTop + chatViewRef.current.clientHeight >= chatViewRef.current.scrollHeight);
}; };
chatViewRef.current?.addEventListener("scroll", handleChatViewScroll); chatViewRef.current?.addEventListener("scroll", handleChatViewScroll);
@ -39,28 +50,21 @@ const ChatView = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
setTimeout(() => { if (!chatViewRef.current) {
if (!chatViewRef.current) { return;
return; }
} chatViewRef.current.scrollTop = chatViewRef.current.scrollHeight;
chatViewRef.current.scrollTop = chatViewRef.current.scrollHeight; }, [currentChat, lastMessage?.id]);
});
}, [currentChat, messageList.length, lastMessage?.isGenerated]);
useEffect(() => { useEffect(() => {
setTimeout(() => { if (!chatViewRef.current) {
if (!chatViewRef.current) { return;
return; }
}
if (!lastMessage) {
return;
}
if (!lastMessage.isGenerated) { if (lastMessage?.status === "LOADING" && isStickyAtBottom) {
chatViewRef.current.scrollTop = chatViewRef.current.scrollHeight; chatViewRef.current.scrollTop = chatViewRef.current.scrollHeight;
} }
}); }, [lastMessage?.status, lastMessage?.content, isStickyAtBottom]);
}, [lastMessage?.isGenerated, lastMessage?.content]);
useEffect(() => { useEffect(() => {
if ( if (
@ -84,14 +88,25 @@ const ChatView = () => {
if (!currentChat) { if (!currentChat) {
return; return;
} }
if (isRequesting) { if (lastMessage?.status === "LOADING") {
return; return;
} }
setIsRequesting(true);
const messageList = messageStore.getState().messageList.filter((message) => message.chatId === currentChat.id); const messageList = messageStore.getState().messageList.filter((message) => message.chatId === currentChat.id);
let prompt = ""; let prompt = "";
let tokens = 0; let tokens = 0;
const message: Message = {
id: generateUUID(),
chatId: currentChat.id,
creatorId: currentChat.assistantId,
creatorRole: CreatorRole.Assistant,
createdAt: Date.now(),
content: "",
status: "LOADING",
};
messageStore.addMessage(message);
if (connectionStore.currentConnectionCtx?.database) { if (connectionStore.currentConnectionCtx?.database) {
let schema = ""; let schema = "";
try { try {
@ -123,13 +138,13 @@ const ChatView = () => {
role: CreatorRole.System, role: CreatorRole.System,
content: prompt, content: prompt,
}); });
const rawRes = await fetch("/api/chat", { const rawRes = await fetch("/api/chat", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
messages: formatedMessageList, messages: formatedMessageList,
}), }),
}); });
setIsRequesting(false);
if (!rawRes.ok) { if (!rawRes.ok) {
console.error(rawRes); console.error(rawRes);
@ -140,7 +155,10 @@ const ChatView = () => {
} catch (error) { } catch (error) {
// do nth // do nth
} }
toast.error(errorMessage); messageStore.updateMessage(message.id, {
content: errorMessage,
status: "FAILED",
});
return; return;
} }
@ -150,17 +168,6 @@ const ChatView = () => {
return; return;
} }
const message: Message = {
id: generateUUID(),
chatId: currentChat.id,
creatorId: currentChat.assistantId,
creatorRole: CreatorRole.Assistant,
createdAt: Date.now(),
content: "",
isGenerated: false,
};
messageStore.addMessage(message);
const reader = data.getReader(); const reader = data.getReader();
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
let done = false; let done = false;
@ -178,7 +185,7 @@ const ChatView = () => {
done = readerDone; done = readerDone;
} }
messageStore.updateMessage(message.id, { messageStore.updateMessage(message.id, {
isGenerated: true, status: "DONE",
}); });
}; };
@ -197,10 +204,9 @@ const ChatView = () => {
) : ( ) : (
messageList.map((message) => <MessageView key={message.id} message={message} />) messageList.map((message) => <MessageView key={message.id} message={message} />)
)} )}
{isRequesting && <MessageLoader />}
</div> </div>
<div className="sticky bottom-0 w-full max-w-4xl py-2 px-4 sm:px-8 mx-auto bg-white bg-opacity-80 backdrop-blur"> <div className="sticky bottom-0 w-full max-w-4xl py-2 px-4 sm:px-8 mx-auto bg-white bg-opacity-80 backdrop-blur">
<MessageTextarea disabled={isRequesting} sendMessage={sendMessageToCurrentChat} /> <MessageTextarea disabled={lastMessage?.status === "LOADING"} sendMessage={sendMessageToCurrentChat} />
</div> </div>
</main> </main>
); );

View File

@ -36,7 +36,7 @@ const EmptyView = (props: Props) => {
creatorRole: CreatorRole.User, creatorRole: CreatorRole.User,
createdAt: Date.now(), createdAt: Date.now(),
content: content, content: content,
isGenerated: true, status: "DONE",
}); });
await sendMessage(); await sendMessage();
}; };

View File

@ -6,6 +6,8 @@ export enum CreatorRole {
Assistant = "assistant", Assistant = "assistant",
} }
type MessageStatus = "LOADING" | "DONE" | "FAILED";
export interface Message { export interface Message {
id: Id; id: Id;
chatId: string; chatId: string;
@ -13,5 +15,5 @@ export interface Message {
creatorRole: CreatorRole; creatorRole: CreatorRole;
createdAt: Timestamp; createdAt: Timestamp;
content: string; content: string;
isGenerated: boolean; status: MessageStatus;
} }