memos/web/src/pages/MemoChat.tsx

195 lines
6.9 KiB
TypeScript

import { Button, Stack } from "@mui/joy";
import { head } from "lodash-es";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslate } from "@/utils/i18n";
import * as api from "@/helpers/api";
import useLoading from "@/hooks/useLoading";
import { useMessageStore } from "@/store/zustand/message";
import { Conversation, useConversationStore } from "@/store/zustand/conversation";
import Icon from "@/components/Icon";
import { generateUUID } from "@/utils/uuid";
import MobileHeader from "@/components/MobileHeader";
import ChatMessage from "@/components/MemoChat/ChatMessage";
import ChatInput from "@/components/MemoChat/ChatInput";
import ConversationTab from "@/components/MemoChat/ConversationTab";
import Empty from "@/components/Empty";
const MemoChat = () => {
const t = useTranslate();
const fetchingState = useLoading(false);
const [isEnabled, setIsEnabled] = useState<boolean>(true);
const [isInIME, setIsInIME] = useState(false);
const [question, setQuestion] = useState<string>("");
const conversationStore = useConversationStore();
const conversationList = conversationStore.conversationList;
const [selectedConversationId, setSelectedConversationId] = useState<string>(head(conversationList)?.messageStorageId || "");
const messageStore = useMessageStore(selectedConversationId)();
const messageList = messageStore.messageList;
// The state didn't show in component, just for trigger re-render
const [message, setMessage] = useState<string>("");
useEffect(() => {
api.checkOpenAIEnabled().then(({ data }) => {
setIsEnabled(data);
});
}, []);
// to new a conversation when no conversation
useEffect(() => {
if (conversationList.length === 0) {
newConversation();
}
}, [conversationList]);
// to select head message conversation(conversation) when conversation be deleted
useEffect(() => {
setSelectedConversationId(head(conversationList)?.messageStorageId || "");
}, [conversationList]);
const handleGotoSystemSetting = () => {
window.open(`/setting`);
};
const handleQuestionTextareaChange = async (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setQuestion(event.currentTarget.value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey && !isInIME) {
event.preventDefault();
handleSendQuestionButtonClick().then();
}
};
const handleSendQuestionButtonClick = async () => {
if (!question) {
return;
}
fetchingState.setLoading();
setQuestion("");
messageStore.addMessage({
id: generateUUID(),
role: "user",
content: question,
});
const messageId = generateUUID();
messageStore.addMessage({
id: messageId,
role: "assistant",
content: "",
});
try {
fetchChatStreaming(messageId);
} catch (error: any) {
console.error(error);
toast.error(error.response.data.error);
}
};
const fetchChatStreaming = async (messageId: string) => {
const messageList = messageStore.getState().messageList;
await api.chatStreaming(
messageList,
async (event: any) => {
messageStore.updateMessage(messageId, event.data);
// to trigger re-render
setMessage(message + event.data);
},
async () => {
fetchingState.setFinish();
}
);
};
const newConversation = () => {
const uuid = generateUUID();
// get the time HH:mm as the default name
const name = new Date().toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
conversationStore.addConversation({
name: name,
messageStorageId: uuid,
});
setSelectedConversationId(uuid);
};
return (
<section className="w-full max-w-3xl min-h-full flex flex-col justify-start items-center px-4 sm:px-2 sm:pt-4 pb-8 bg-zinc-100 dark:bg-zinc-800">
<MobileHeader showSearch={false} />
<div className="w-full flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-white dark:bg-zinc-700 text-black dark:text-gray-300">
<div className="w-full flex">
<div className="w-auto flex flex-row justify-start items-center select-none shrink-0 mr-4">
<Icon.Bot className="w-5 h-auto mr-1" /> {t("memo-chat.title")}
</div>
<div className="flex flex-row w-auto justify-start items-center overflow-y-hidden overflow-x-auto">
<div className="flex space-x-2 overflow-x-auto">
{conversationList.map((item: Conversation) => (
<ConversationTab
key={item.messageStorageId}
item={item}
selectedConversationId={selectedConversationId}
setSelectedConversationId={setSelectedConversationId}
closeConversation={(e) => {
// this is very important. otherwise, the select event also be clicked.
e.stopPropagation();
conversationStore.removeConversation(item);
toast.success("Remove successfully");
}}
/>
))}
</div>
<button className="btn-text px-1 ml-1 shrink-0">
<Icon.Plus
className="w-4 h-auto"
onClick={() => {
newConversation();
}}
/>
</button>
</div>
</div>
<div className="dialog-content-container w-full">
<Stack spacing={2} style={{ width: "100%" }}>
{messageList.length == 0 && (
<div className="w-full mt-8 mb-8 flex flex-col justify-center items-center italic">
<Empty />
<p className="mt-4 text-gray-600 dark:text-gray-400">{t("memo-chat.no-message")}</p>
</div>
)}
{messageList.map((message, index) => (
<ChatMessage key={index} message={message} index={index} />
))}
</Stack>
{fetchingState.isLoading && (
<p className="w-full py-2 mt-4 flex flex-row justify-center items-center">
<Icon.Loader className="w-5 h-auto animate-spin" />
</p>
)}
{!isEnabled && (
<div className="w-full flex flex-col justify-center items-center mt-4 space-y-2">
<p>{t("memo-chat.not-enabled")}</p>
<Button onClick={() => handleGotoSystemSetting()}>{t("memo-chat.go-to-settings")}</Button>
</div>
)}
<ChatInput
question={question}
handleQuestionTextareaChange={handleQuestionTextareaChange}
setIsInIME={setIsInIME}
handleKeyDown={handleKeyDown}
handleSendQuestionButtonClick={handleSendQuestionButtonClick}
/>
</div>
</div>
</section>
);
};
export default MemoChat;