mirror of https://github.com/usememos/memos
feat: implement memo chat frontend (#1938)
* feat: implment backend function * feat: implment frontend component * stash * eslint * eslint * eslint * delete node * stash * refactor the style * eslint * eslint * eslint * fix build error * add dep * Update web/src/components/MemosChat/ConversationTab.tsx Co-authored-by: boojack <stevenlgtm@gmail.com> * Update web/src/components/MemosChat/ConversationTab.tsx Co-authored-by: boojack <stevenlgtm@gmail.com> * feat: change the name * disable for vistor --------- Co-authored-by: boojack <stevenlgtm@gmail.com>pull/1950/head
parent
06dbd87311
commit
39351970d0
@ -0,0 +1,33 @@
|
||||
import { Conversation } from "@/store/zustand/conversation";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
interface ConversationTabProps {
|
||||
item: Conversation;
|
||||
selectedConversationId: string;
|
||||
setSelectedConversationId: (id: string) => void;
|
||||
closeConversation: (e: any) => void;
|
||||
}
|
||||
|
||||
const ConversationTab = ({ item, selectedConversationId, setSelectedConversationId, closeConversation }: ConversationTabProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex rounded-lg h-8 px-3 border dark:border-zinc-600 ${
|
||||
selectedConversationId === item.messageStorageId ? "bg-white dark:bg-zinc-700" : "bg-gray-200 dark:bg-zinc-800 opacity-60"
|
||||
}`}
|
||||
key={item.messageStorageId}
|
||||
onClick={() => {
|
||||
setSelectedConversationId(item.messageStorageId);
|
||||
}}
|
||||
>
|
||||
<div className="truncate m-auto">{item.name}</div>
|
||||
<Icon.X
|
||||
className="w-4 h-auto m-auto cursor-pointer"
|
||||
onClick={(e: any) => {
|
||||
closeConversation(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationTab;
|
@ -0,0 +1,42 @@
|
||||
import Icon from "@/components/Icon";
|
||||
import Textarea from "@mui/joy/Textarea/Textarea";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MemosChatInputProps {
|
||||
question: string;
|
||||
handleQuestionTextareaChange: any;
|
||||
setIsInIME: any;
|
||||
handleKeyDown: any;
|
||||
handleSendQuestionButtonClick: any;
|
||||
}
|
||||
const MemosChatInput = ({
|
||||
question,
|
||||
handleQuestionTextareaChange,
|
||||
setIsInIME,
|
||||
handleKeyDown,
|
||||
handleSendQuestionButtonClick,
|
||||
}: MemosChatInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="w-full relative mt-4">
|
||||
<Textarea
|
||||
className="w-full"
|
||||
placeholder={t("memo-chat.placeholder")}
|
||||
value={question}
|
||||
minRows={1}
|
||||
maxRows={5}
|
||||
onChange={handleQuestionTextareaChange}
|
||||
onCompositionStart={() => setIsInIME(true)}
|
||||
onCompositionEnd={() => setIsInIME(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Icon.Send
|
||||
className="cursor-pointer w-7 p-1 h-auto rounded-md bg-gray-100 dark:bg-zinc-800 absolute right-2 bottom-1.5 shadow hover:opacity-80"
|
||||
onClick={handleSendQuestionButtonClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemosChatInput;
|
@ -0,0 +1,31 @@
|
||||
import { Message } from "@/store/zustand/message";
|
||||
import { marked } from "@/labs/marked";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
interface MessageProps {
|
||||
index: number;
|
||||
message: Message;
|
||||
}
|
||||
|
||||
const MemosChatMessage = ({ index, message }: MessageProps) => {
|
||||
return (
|
||||
<div key={index} className="w-full flex flex-col justify-start items-start space-y-2">
|
||||
{message.role === "user" ? (
|
||||
<div className="w-full flex flex-row justify-end items-start pl-6">
|
||||
<span className="word-break shadow rounded-lg rounded-tr-none px-3 py-2 opacity-80 bg-white dark:bg-zinc-800">
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex flex-row justify-start items-start pr-8 space-x-2">
|
||||
<Icon.Bot className="mt-2 shrink-0 mr-1 w-6 h-auto opacity-80" />
|
||||
<div className="memo-content-wrapper !w-auto flex flex-col justify-start items-start shadow rounded-lg rounded-tl-none px-3 py-2 bg-white dark:bg-zinc-800">
|
||||
<div className="memo-content-text">{marked(message.content)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemosChatMessage;
|
@ -0,0 +1,201 @@
|
||||
import { Button, Stack } from "@mui/joy";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 MemosChatMessage from "@/components/MemosChat/MemosChatMessage";
|
||||
import MemosChatInput from "@/components/MemosChat/MemosChatInput";
|
||||
import head from "lodash-es/head";
|
||||
import ConversationTab from "@/components/MemosChat/ConversationTab";
|
||||
import Empty from "@/components/Empty";
|
||||
|
||||
const MemosChat = () => {
|
||||
const { t } = useTranslation();
|
||||
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-2xl 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="flex space-x-2">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<p className="flex flex-row justify-start items-center select-none rounded">
|
||||
<Icon.Bot className="w-5 h-auto mr-1" /> {t("memo-chat.title")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="flex flex-row w-full justify-start items-center">
|
||||
<div className="flex space-x-2 max-w-md overflow-scroll">
|
||||
{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">
|
||||
<Icon.Plus
|
||||
className="w-4 h-auto"
|
||||
onClick={() => {
|
||||
newConversation();
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</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) => (
|
||||
<MemosChatMessage 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>
|
||||
)}
|
||||
|
||||
<MemosChatInput
|
||||
question={question}
|
||||
handleQuestionTextareaChange={handleQuestionTextareaChange}
|
||||
setIsInIME={setIsInIME}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSendQuestionButtonClick={handleSendQuestionButtonClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemosChat;
|
@ -0,0 +1,43 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { t } from "i18next";
|
||||
|
||||
export interface Conversation {
|
||||
name: string;
|
||||
messageStorageId: string;
|
||||
}
|
||||
|
||||
interface ConversationState {
|
||||
conversationList: Conversation[];
|
||||
getState: () => ConversationState;
|
||||
addConversation: (conversation: Conversation) => void;
|
||||
removeConversation: (conversation: Conversation) => Conversation;
|
||||
}
|
||||
|
||||
export const defaultConversation: Conversation = {
|
||||
name: t("ask-ai.default-message-conversation-title"),
|
||||
messageStorageId: "message-storage",
|
||||
};
|
||||
|
||||
export const useConversationStore = create<ConversationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
conversationList: [],
|
||||
getState: () => get(),
|
||||
addConversation: (conversation: Conversation) => set((state) => ({ conversationList: [...state.conversationList, conversation] })),
|
||||
removeConversation: (conversation: Conversation) => {
|
||||
set((state) => ({
|
||||
conversationList: state.conversationList.filter(
|
||||
(i) => i.name != conversation.name || i.messageStorageId != conversation.messageStorageId
|
||||
),
|
||||
}));
|
||||
localStorage.removeItem(conversation.messageStorageId);
|
||||
const conversationList = get().conversationList;
|
||||
return conversationList.length > 0 ? conversationList[conversationList.length - 1] : defaultConversation;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "message-conversation-storage",
|
||||
}
|
||||
)
|
||||
);
|
@ -0,0 +1,37 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface MessageState {
|
||||
messageList: Message[];
|
||||
getState: () => MessageState;
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (id: string, appendContent: string) => void;
|
||||
}
|
||||
|
||||
export const useMessageStore = (messageStorageId: string) => {
|
||||
return create<MessageState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
messageList: [] as Message[],
|
||||
getState: () => get(),
|
||||
addMessage: (message: Message) => {
|
||||
return set((state) => ({ messageList: [...state.messageList, message] }));
|
||||
},
|
||||
updateMessage: (id: string, appendContent: string) =>
|
||||
set((state) => ({
|
||||
...state,
|
||||
messageList: state.messageList.map((item) => (item.id === id ? { ...item, content: item.content + appendContent } : item)),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: messageStorageId,
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export const generateUUID = () => {
|
||||
return uuidv4();
|
||||
};
|
Loading…
Reference in New Issue