mirror of
https://github.com/usememos/memos.git
synced 2025-02-19 12:50:41 +01:00
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>
This commit is contained in:
parent
06dbd87311
commit
39351970d0
@ -67,7 +67,7 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
|||||||
Skipper: func(c echo.Context) bool {
|
Skipper: func(c echo.Context) bool {
|
||||||
// this is a hack to skip timeout for openai chat streaming
|
// this is a hack to skip timeout for openai chat streaming
|
||||||
// because streaming require to flush response. But the timeout middleware will break it.
|
// because streaming require to flush response. But the timeout middleware will break it.
|
||||||
return c.Request().URL.Path == "/api/openai/chat-streaming"
|
return c.Request().URL.Path == "/api/v1/openai/chat-streaming"
|
||||||
},
|
},
|
||||||
ErrorMessage: "Request timeout",
|
ErrorMessage: "Request timeout",
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.5",
|
"@emotion/react": "^11.10.5",
|
||||||
"@emotion/styled": "^11.10.5",
|
"@emotion/styled": "^11.10.5",
|
||||||
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"@mui/joy": "^5.0.0-alpha.75",
|
"@mui/joy": "^5.0.0-alpha.75",
|
||||||
"@reduxjs/toolkit": "^1.8.1",
|
"@reduxjs/toolkit": "^1.8.1",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
@ -29,6 +30,7 @@
|
|||||||
"react-use": "^17.4.0",
|
"react-use": "^17.4.0",
|
||||||
"semver": "^7.3.8",
|
"semver": "^7.3.8",
|
||||||
"tailwindcss": "^3.2.4",
|
"tailwindcss": "^3.2.4",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
"zustand": "^4.3.6"
|
"zustand": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -38,6 +40,7 @@
|
|||||||
"@types/react": "^18.0.26",
|
"@types/react": "^18.0.26",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@types/semver": "^7.3.13",
|
"@types/semver": "^7.3.13",
|
||||||
|
"@types/uuid": "^9.0.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
||||||
"@typescript-eslint/parser": "^5.6.0",
|
"@typescript-eslint/parser": "^5.6.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.3.0",
|
"@vitejs/plugin-react-swc": "^3.3.0",
|
||||||
|
24
web/pnpm-lock.yaml
generated
24
web/pnpm-lock.yaml
generated
@ -1,4 +1,4 @@
|
|||||||
lockfileVersion: '6.1'
|
lockfileVersion: '6.0'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
@ -11,6 +11,9 @@ dependencies:
|
|||||||
'@emotion/styled':
|
'@emotion/styled':
|
||||||
specifier: ^11.10.5
|
specifier: ^11.10.5
|
||||||
version: 11.10.5(@emotion/react@11.10.5)(@types/react@18.0.26)(react@18.2.0)
|
version: 11.10.5(@emotion/react@11.10.5)(@types/react@18.0.26)(react@18.2.0)
|
||||||
|
'@microsoft/fetch-event-source':
|
||||||
|
specifier: ^2.0.1
|
||||||
|
version: 2.0.1
|
||||||
'@mui/joy':
|
'@mui/joy':
|
||||||
specifier: ^5.0.0-alpha.75
|
specifier: ^5.0.0-alpha.75
|
||||||
version: 5.0.0-alpha.75(@emotion/react@11.10.5)(@emotion/styled@11.10.5)(@types/react@18.0.26)(react-dom@18.2.0)(react@18.2.0)
|
version: 5.0.0-alpha.75(@emotion/react@11.10.5)(@emotion/styled@11.10.5)(@types/react@18.0.26)(react-dom@18.2.0)(react@18.2.0)
|
||||||
@ -68,6 +71,9 @@ dependencies:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(postcss@8.4.21)
|
version: 3.2.4(postcss@8.4.21)
|
||||||
|
uuid:
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.0
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6(react@18.2.0)
|
version: 4.3.6(react@18.2.0)
|
||||||
@ -91,6 +97,9 @@ devDependencies:
|
|||||||
'@types/semver':
|
'@types/semver':
|
||||||
specifier: ^7.3.13
|
specifier: ^7.3.13
|
||||||
version: 7.3.13
|
version: 7.3.13
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: ^9.0.2
|
||||||
|
version: 9.0.2
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.6.0(@typescript-eslint/parser@5.6.0)(eslint@8.4.1)(typescript@5.0.4)
|
version: 5.6.0(@typescript-eslint/parser@5.6.0)(eslint@8.4.1)(typescript@5.0.4)
|
||||||
@ -578,6 +587,10 @@ packages:
|
|||||||
'@jridgewell/sourcemap-codec': 1.4.14
|
'@jridgewell/sourcemap-codec': 1.4.14
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@microsoft/fetch-event-source@2.0.1:
|
||||||
|
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@mui/base@5.0.0-alpha.125(@types/react@18.0.26)(react-dom@18.2.0)(react@18.2.0):
|
/@mui/base@5.0.0-alpha.125(@types/react@18.0.26)(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-hAHJJ97SATu6SrkLH/HsAayK1zMZt89lrWyKuAInBKVyn363H78d1MnwyZwre9vDK5MrPoDL/NnZxtAXhwTnBA==}
|
resolution: {integrity: sha512-hAHJJ97SATu6SrkLH/HsAayK1zMZt89lrWyKuAInBKVyn363H78d1MnwyZwre9vDK5MrPoDL/NnZxtAXhwTnBA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@ -961,6 +974,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
|
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/uuid@9.0.2:
|
||||||
|
resolution: {integrity: sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@typescript-eslint/eslint-plugin@5.6.0(@typescript-eslint/parser@5.6.0)(eslint@8.4.1)(typescript@5.0.4):
|
/@typescript-eslint/eslint-plugin@5.6.0(@typescript-eslint/parser@5.6.0)(eslint@8.4.1)(typescript@5.0.4):
|
||||||
resolution: {integrity: sha512-MIbeMy5qfLqtgs1hWd088k1hOuRsN9JrHUPwVVKCD99EOUqScd7SrwoZl4Gso05EAP9w1kvLWUVGJOVpRPkDPA==}
|
resolution: {integrity: sha512-MIbeMy5qfLqtgs1hWd088k1hOuRsN9JrHUPwVVKCD99EOUqScd7SrwoZl4Gso05EAP9w1kvLWUVGJOVpRPkDPA==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
@ -3403,6 +3420,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/uuid@9.0.0:
|
||||||
|
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
|
||||||
|
hasBin: true
|
||||||
|
dev: false
|
||||||
|
|
||||||
/v8-compile-cache@2.3.0:
|
/v8-compile-cache@2.3.0:
|
||||||
resolution: {integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==}
|
resolution: {integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -103,8 +103,22 @@ const Header = () => {
|
|||||||
<Icon.Hash className="mr-3 w-6 h-auto opacity-70" /> {t("common.explore")}
|
<Icon.Hash className="mr-3 w-6 h-auto opacity-70" /> {t("common.explore")}
|
||||||
</>
|
</>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{!isVisitorMode && (
|
{!isVisitorMode && (
|
||||||
<>
|
<>
|
||||||
|
<NavLink
|
||||||
|
to="/memo-chat"
|
||||||
|
id="header-memo-chat"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${
|
||||||
|
isActive && "bg-white dark:bg-zinc-700 shadow"
|
||||||
|
} px-4 pr-5 py-2 rounded-full flex flex-row items-center text-lg text-gray-800 dark:text-gray-300 hover:bg-white hover:shadow dark:hover:bg-zinc-700`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<Icon.Bot className="mr-3 w-6 h-auto opacity-70" /> {t("memo-chat.title")}
|
||||||
|
</>
|
||||||
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/archived"
|
to="/archived"
|
||||||
id="header-archived"
|
id="header-archived"
|
||||||
|
33
web/src/components/MemosChat/ConversationTab.tsx
Normal file
33
web/src/components/MemosChat/ConversationTab.tsx
Normal file
@ -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;
|
42
web/src/components/MemosChat/MemosChatInput.tsx
Normal file
42
web/src/components/MemosChat/MemosChatInput.tsx
Normal file
@ -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;
|
31
web/src/components/MemosChat/MemosChatMessage.tsx
Normal file
31
web/src/components/MemosChat/MemosChatMessage.tsx
Normal file
@ -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;
|
@ -1,4 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
||||||
|
import { Message } from "@/store/zustand/message";
|
||||||
|
|
||||||
export function getSystemStatus() {
|
export function getSystemStatus() {
|
||||||
return axios.get<SystemStatus>("/api/v1/status");
|
return axios.get<SystemStatus>("/api/v1/status");
|
||||||
@ -133,6 +135,31 @@ export function unpinMemo(memoId: MemoId) {
|
|||||||
export function deleteMemo(memoId: MemoId) {
|
export function deleteMemo(memoId: MemoId) {
|
||||||
return axios.delete(`/api/v1/memo/${memoId}`);
|
return axios.delete(`/api/v1/memo/${memoId}`);
|
||||||
}
|
}
|
||||||
|
export function checkOpenAIEnabled() {
|
||||||
|
return axios.get<boolean>(`/api/openai/enabled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatStreaming(messageList: Array<Message>, onmessage: any, onclose: any) {
|
||||||
|
await fetchEventSource("/api/v1/openai/chat-streaming", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(messageList),
|
||||||
|
async onopen() {
|
||||||
|
// to do nth
|
||||||
|
},
|
||||||
|
onmessage(event: any) {
|
||||||
|
onmessage(event);
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
onclose();
|
||||||
|
},
|
||||||
|
onerror(error: any) {
|
||||||
|
console.log("error", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getShortcutList(shortcutFind?: ShortcutFind) {
|
export function getShortcutList(shortcutFind?: ShortcutFind) {
|
||||||
const queryList = [];
|
const queryList = [];
|
||||||
|
@ -378,11 +378,12 @@
|
|||||||
"sat": "Sat",
|
"sat": "Sat",
|
||||||
"sun": "Sun"
|
"sun": "Sun"
|
||||||
},
|
},
|
||||||
"ask-ai": {
|
"memo-chat": {
|
||||||
"title": "Ask AI",
|
"title": "Memo Chat",
|
||||||
"not-enabled": "You have not set up your OpenAI API key.",
|
"not-enabled": "You have not set up your OpenAI API key.",
|
||||||
"go-to-settings": "Go to settings",
|
"go-to-settings": "Go to settings",
|
||||||
"placeholder": "Ask anything…",
|
"placeholder": "Ask anything…",
|
||||||
|
"no-message": "No Message",
|
||||||
"default-message-group-title": "Default Session",
|
"default-message-group-title": "Default Session",
|
||||||
"create-message-group-title": "Create Session",
|
"create-message-group-title": "Create Session",
|
||||||
"label-message-group-name-title": "Session Name"
|
"label-message-group-name-title": "Session Name"
|
||||||
|
@ -376,7 +376,7 @@
|
|||||||
"sat": "土",
|
"sat": "土",
|
||||||
"sun": "日"
|
"sun": "日"
|
||||||
},
|
},
|
||||||
"ask-ai": {
|
"memo-chat": {
|
||||||
"title": "AIに尋ねる",
|
"title": "AIに尋ねる",
|
||||||
"not-enabled": "OpenAIのAPI keyが設定されていません",
|
"not-enabled": "OpenAIのAPI keyが設定されていません",
|
||||||
"go-to-settings": "設定へ",
|
"go-to-settings": "設定へ",
|
||||||
@ -385,6 +385,7 @@
|
|||||||
"create-message-group-title": "セッションを作成する",
|
"create-message-group-title": "セッションを作成する",
|
||||||
"label-message-group-name-title": "セッションの名前"
|
"label-message-group-name-title": "セッションの名前"
|
||||||
},
|
},
|
||||||
|
|
||||||
"embed-memo": {
|
"embed-memo": {
|
||||||
"title": "メモを埋め込む",
|
"title": "メモを埋め込む",
|
||||||
"text": "コードをあなたのサイズにコピーペーストすればメモを埋め込めます",
|
"text": "コードをあなたのサイズにコピーペーストすればメモを埋め込めます",
|
||||||
|
@ -95,7 +95,7 @@
|
|||||||
"fetching-data": "请求数据中...",
|
"fetching-data": "请求数据中...",
|
||||||
"no-archived-memos": "没有归档的备忘录"
|
"no-archived-memos": "没有归档的备忘录"
|
||||||
},
|
},
|
||||||
"ask-ai": {
|
"memo-chat": {
|
||||||
"go-to-settings": "前往设置",
|
"go-to-settings": "前往设置",
|
||||||
"not-enabled": "您尚未设置 OpenAI API 密钥。",
|
"not-enabled": "您尚未设置 OpenAI API 密钥。",
|
||||||
"placeholder": "随便问",
|
"placeholder": "随便问",
|
||||||
|
@ -400,7 +400,7 @@
|
|||||||
"friday": "星期五",
|
"friday": "星期五",
|
||||||
"wednesday": "星期三"
|
"wednesday": "星期三"
|
||||||
},
|
},
|
||||||
"ask-ai": {
|
"memo-chat": {
|
||||||
"title": "問 AI",
|
"title": "問 AI",
|
||||||
"not-enabled": "您尚未設置 OpenAI API 密鑰。",
|
"not-enabled": "您尚未設置 OpenAI API 密鑰。",
|
||||||
"go-to-settings": "前往設定",
|
"go-to-settings": "前往設定",
|
||||||
@ -409,6 +409,7 @@
|
|||||||
"create-message-group-title": "新建對話",
|
"create-message-group-title": "新建對話",
|
||||||
"label-message-group-name-title": "對話名稱"
|
"label-message-group-name-title": "對話名稱"
|
||||||
},
|
},
|
||||||
|
|
||||||
"embed-memo": {
|
"embed-memo": {
|
||||||
"title": "嵌入 Memo",
|
"title": "嵌入 Memo",
|
||||||
"text": "將以下代碼複製並貼上到您的網誌或網站中。",
|
"text": "將以下代碼複製並貼上到您的網誌或網站中。",
|
||||||
|
201
web/src/pages/MemosChat.tsx
Normal file
201
web/src/pages/MemosChat.tsx
Normal file
@ -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;
|
@ -16,6 +16,7 @@ const Home = lazy(() => import("@/pages/Home"));
|
|||||||
const MemoDetail = lazy(() => import("@/pages/MemoDetail"));
|
const MemoDetail = lazy(() => import("@/pages/MemoDetail"));
|
||||||
const EmbedMemo = lazy(() => import("@/pages/EmbedMemo"));
|
const EmbedMemo = lazy(() => import("@/pages/EmbedMemo"));
|
||||||
const NotFound = lazy(() => import("@/pages/NotFound"));
|
const NotFound = lazy(() => import("@/pages/NotFound"));
|
||||||
|
const MemosChat = lazy(() => import("@/pages/MemosChat"));
|
||||||
|
|
||||||
const initialGlobalStateLoader = (() => {
|
const initialGlobalStateLoader = (() => {
|
||||||
let done = false;
|
let done = false;
|
||||||
@ -147,6 +148,26 @@ const router = createBrowserRouter([
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "memo-chat",
|
||||||
|
element: <MemosChat />,
|
||||||
|
loader: async () => {
|
||||||
|
await initialGlobalStateLoader();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initialUserState();
|
||||||
|
} catch (error) {
|
||||||
|
// do nth
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host } = store.getState().user;
|
||||||
|
if (isNullorUndefined(host)) {
|
||||||
|
return redirect("/auth");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "archived",
|
path: "archived",
|
||||||
element: <Archived />,
|
element: <Archived />,
|
||||||
|
43
web/src/store/zustand/conversation.ts
Normal file
43
web/src/store/zustand/conversation.ts
Normal file
@ -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",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
37
web/src/store/zustand/message.ts
Normal file
37
web/src/store/zustand/message.ts
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
5
web/src/utils/uuid.ts
Normal file
5
web/src/utils/uuid.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
export const generateUUID = () => {
|
||||||
|
return uuidv4();
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user