From 6c1862797bb6d27c271d3cf0a3f80937e6f0c361 Mon Sep 17 00:00:00 2001 From: Yifei Zhang Date: Sun, 2 Apr 2023 15:05:54 +0000 Subject: [PATCH] refactor: split homt.tsx components --- app/components/chat-list.tsx | 69 +++++ app/components/chat.tsx | 499 ++++++++++++++++++++++++++++++++ app/components/home.tsx | 533 +---------------------------------- app/components/settings.tsx | 2 +- 4 files changed, 572 insertions(+), 531 deletions(-) create mode 100644 app/components/chat-list.tsx create mode 100644 app/components/chat.tsx diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx new file mode 100644 index 00000000..5a74ff15 --- /dev/null +++ b/app/components/chat-list.tsx @@ -0,0 +1,69 @@ +import { useState, useRef, useEffect, useLayoutEffect } from "react"; +import DeleteIcon from "../icons/delete.svg"; +import styles from "./home.module.scss"; + +import { + Message, + SubmitKey, + useChatStore, + ChatSession, + BOT_HELLO, +} from "../store"; + +import Locale from "../locales"; + +export function ChatItem(props: { + onClick?: () => void; + onDelete?: () => void; + title: string; + count: number; + time: string; + selected: boolean; +}) { + return ( +
+
{props.title}
+
+
+ {Locale.ChatItem.ChatItemCount(props.count)} +
+
{props.time}
+
+
+ +
+
+ ); +} + +export function ChatList() { + const [sessions, selectedIndex, selectSession, removeSession] = useChatStore( + (state) => [ + state.sessions, + state.currentSessionIndex, + state.selectSession, + state.removeSession, + ], + ); + + return ( +
+ {sessions.map((item, i) => ( + selectSession(i)} + onDelete={() => confirm(Locale.Home.DeleteChat) && removeSession(i)} + /> + ))} +
+ ); +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx new file mode 100644 index 00000000..348fe2ea --- /dev/null +++ b/app/components/chat.tsx @@ -0,0 +1,499 @@ +import { useDebouncedCallback } from "use-debounce"; +import { useState, useRef, useEffect, useLayoutEffect } from "react"; + +import SendWhiteIcon from "../icons/send-white.svg"; +import BrainIcon from "../icons/brain.svg"; +import ExportIcon from "../icons/export.svg"; +import MenuIcon from "../icons/menu.svg"; +import CopyIcon from "../icons/copy.svg"; +import DownloadIcon from "../icons/download.svg"; +import LoadingIcon from "../icons/three-dots.svg"; +import BotIcon from "../icons/bot.svg"; + +import { + Message, + SubmitKey, + useChatStore, + ChatSession, + BOT_HELLO, +} from "../store"; + +import { + copyToClipboard, + downloadAs, + isMobileScreen, + selectOrCopy, +} from "../utils"; + +import dynamic from "next/dynamic"; + +import { ControllerPool } from "../requests"; +import { Prompt, usePromptStore } from "../store/prompt"; +import Locale from "../locales"; + +import { IconButton } from "./button"; +import styles from "./home.module.scss"; + +import { showModal, showToast } from "./ui-lib"; + +const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { + loading: () => , +}); + +const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, { + loading: () => , +}); + +export function Avatar(props: { role: Message["role"] }) { + const config = useChatStore((state) => state.config); + + if (props.role === "assistant") { + return ; + } + + return ( +
+ +
+ ); +} + +function exportMessages(messages: Message[], topic: string) { + const mdText = + `# ${topic}\n\n` + + messages + .map((m) => { + return m.role === "user" ? `## ${m.content}` : m.content.trim(); + }) + .join("\n\n"); + const filename = `${topic}.md`; + + showModal({ + title: Locale.Export.Title, + children: ( +
+
{mdText}
+
+ ), + actions: [ + } + bordered + text={Locale.Export.Copy} + onClick={() => copyToClipboard(mdText)} + />, + } + bordered + text={Locale.Export.Download} + onClick={() => downloadAs(mdText, filename)} + />, + ], + }); +} + +function showMemoryPrompt(session: ChatSession) { + showModal({ + title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`, + children: ( +
+
+          {session.memoryPrompt || Locale.Memory.EmptyContent}
+        
+
+ ), + actions: [ + } + bordered + text={Locale.Memory.Copy} + onClick={() => copyToClipboard(session.memoryPrompt)} + />, + ], + }); +} + +function useSubmitHandler() { + const config = useChatStore((state) => state.config); + const submitKey = config.submitKey; + + const shouldSubmit = (e: React.KeyboardEvent) => { + if (e.key !== "Enter") return false; + if (e.key === "Enter" && e.nativeEvent.isComposing) return false; + return ( + (config.submitKey === SubmitKey.AltEnter && e.altKey) || + (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) || + (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) || + (config.submitKey === SubmitKey.MetaEnter && e.metaKey) || + (config.submitKey === SubmitKey.Enter && + !e.altKey && + !e.ctrlKey && + !e.shiftKey && + !e.metaKey) + ); + }; + + return { + submitKey, + shouldSubmit, + }; +} + +export function PromptHints(props: { + prompts: Prompt[]; + onPromptSelect: (prompt: Prompt) => void; +}) { + if (props.prompts.length === 0) return null; + + return ( +
+ {props.prompts.map((prompt, i) => ( +
props.onPromptSelect(prompt)} + > +
{prompt.title}
+
{prompt.content}
+
+ ))} +
+ ); +} + +export function Chat(props: { + showSideBar?: () => void; + sideBarShowing?: boolean; +}) { + type RenderMessage = Message & { preview?: boolean }; + + const chatStore = useChatStore(); + const [session, sessionIndex] = useChatStore((state) => [ + state.currentSession(), + state.currentSessionIndex, + ]); + const fontSize = useChatStore((state) => state.config.fontSize); + + const inputRef = useRef(null); + const [userInput, setUserInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const { submitKey, shouldSubmit } = useSubmitHandler(); + + // prompt hints + const promptStore = usePromptStore(); + const [promptHints, setPromptHints] = useState([]); + const onSearch = useDebouncedCallback( + (text: string) => { + setPromptHints(promptStore.search(text)); + }, + 100, + { leading: true, trailing: true }, + ); + + const onPromptSelect = (prompt: Prompt) => { + setUserInput(prompt.content); + setPromptHints([]); + inputRef.current?.focus(); + }; + + const scrollInput = () => { + const dom = inputRef.current; + if (!dom) return; + const paddingBottomNum: number = parseInt( + window.getComputedStyle(dom).paddingBottom, + 10, + ); + dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum; + }; + + // only search prompts when user input is short + const SEARCH_TEXT_LIMIT = 30; + const onInput = (text: string) => { + scrollInput(); + setUserInput(text); + const n = text.trim().length; + + // clear search results + if (n === 0) { + setPromptHints([]); + } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { + // check if need to trigger auto completion + if (text.startsWith("/") && text.length > 1) { + onSearch(text.slice(1)); + } + } + }; + + // submit user input + const onUserSubmit = () => { + if (userInput.length <= 0) return; + setIsLoading(true); + chatStore.onUserInput(userInput).then(() => setIsLoading(false)); + setUserInput(""); + setPromptHints([]); + inputRef.current?.focus(); + }; + + // stop response + const onUserStop = (messageIndex: number) => { + console.log(ControllerPool, sessionIndex, messageIndex); + ControllerPool.stop(sessionIndex, messageIndex); + }; + + // check if should send message + const onInputKeyDown = (e: React.KeyboardEvent) => { + if (shouldSubmit(e)) { + onUserSubmit(); + e.preventDefault(); + } + }; + const onRightClick = (e: any, message: Message) => { + // auto fill user input + if (message.role === "user") { + setUserInput(message.content); + } + + // copy to clipboard + if (selectOrCopy(e.currentTarget, message.content)) { + e.preventDefault(); + } + }; + + const onResend = (botIndex: number) => { + // find last user input message and resend + for (let i = botIndex; i >= 0; i -= 1) { + if (messages[i].role === "user") { + setIsLoading(true); + chatStore + .onUserInput(messages[i].content) + .then(() => setIsLoading(false)); + inputRef.current?.focus(); + return; + } + } + }; + + // for auto-scroll + const latestMessageRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + const config = useChatStore((state) => state.config); + + // preview messages + const messages = (session.messages as RenderMessage[]) + .concat( + isLoading + ? [ + { + role: "assistant", + content: "……", + date: new Date().toLocaleString(), + preview: true, + }, + ] + : [], + ) + .concat( + userInput.length > 0 && config.sendPreviewBubble + ? [ + { + role: "user", + content: userInput, + date: new Date().toLocaleString(), + preview: false, + }, + ] + : [], + ); + + // auto scroll + useLayoutEffect(() => { + setTimeout(() => { + const dom = latestMessageRef.current; + const inputDom = inputRef.current; + + // only scroll when input overlaped message body + let shouldScroll = true; + if (dom && inputDom) { + const domRect = dom.getBoundingClientRect(); + const inputRect = inputDom.getBoundingClientRect(); + shouldScroll = domRect.top > inputRect.top; + } + + if (dom && autoScroll && shouldScroll) { + dom.scrollIntoView({ + block: "end", + }); + } + }, 500); + }); + + return ( +
+
+
+
{ + const newTopic = prompt(Locale.Chat.Rename, session.topic); + if (newTopic && newTopic !== session.topic) { + chatStore.updateCurrentSession( + (session) => (session.topic = newTopic!), + ); + } + }} + > + {session.topic} +
+
+ {Locale.Chat.SubTitle(session.messages.length)} +
+
+
+
+ } + bordered + title={Locale.Chat.Actions.ChatList} + onClick={props?.showSideBar} + /> +
+
+ } + bordered + title={Locale.Chat.Actions.CompressedHistory} + onClick={() => { + showMemoryPrompt(session); + }} + /> +
+
+ } + bordered + title={Locale.Chat.Actions.Export} + onClick={() => { + exportMessages(session.messages, session.topic); + }} + /> +
+
+
+ +
+ {messages.map((message, i) => { + const isUser = message.role === "user"; + + return ( +
+
+
+ +
+ {(message.preview || message.streaming) && ( +
+ {Locale.Chat.Typing} +
+ )} +
+ {!isUser && + !(message.preview || message.content.length === 0) && ( +
+ {message.streaming ? ( +
onUserStop(i)} + > + {Locale.Chat.Actions.Stop} +
+ ) : ( +
onResend(i)} + > + {Locale.Chat.Actions.Retry} +
+ )} + +
copyToClipboard(message.content)} + > + {Locale.Chat.Actions.Copy} +
+
+ )} + {(message.preview || message.content.length === 0) && + !isUser ? ( + + ) : ( +
onRightClick(e, message)} + onDoubleClickCapture={() => { + if (!isMobileScreen()) return; + setUserInput(message.content); + }} + > + +
+ )} +
+ {!isUser && !message.preview && ( +
+
+ {message.date.toLocaleString()} +
+
+ )} +
+
+ ); + })} +
+ - +
+
+ +
+ +
+