import { useDebouncedCallback } from "use-debounce"; import { memo, 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 AddIcon from "../icons/add.svg"; import DeleteIcon from "../icons/delete.svg"; import { Message, SubmitKey, useChatStore, BOT_HELLO, ROLES } from "../store"; import { copyToClipboard, downloadAs, getEmojiUrl, 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 chatStyle from "./chat.module.scss"; import { Modal, showModal, showToast } from "./ui-lib"; const Markdown = dynamic(async () => memo((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 !== "user") { return ; } return (
); } function exportMessages(messages: Message[], topic: string) { const mdText = `# ${topic}\n\n` + messages .map((m) => { return m.role === "user" ? `## ${Locale.Export.MessageFromYou}:\n${m.content}` : `## ${Locale.Export.MessageFromChatGPT}:\n${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 PromptToast(props: { showToast?: boolean; showModal?: boolean; setShowModal: (_: boolean) => void; }) { const chatStore = useChatStore(); const session = chatStore.currentSession(); const context = session.context; const addContextPrompt = (prompt: Message) => { chatStore.updateCurrentSession((session) => { session.context.push(prompt); }); }; const removeContextPrompt = (i: number) => { chatStore.updateCurrentSession((session) => { session.context.splice(i, 1); }); }; const updateContextPrompt = (i: number, prompt: Message) => { chatStore.updateCurrentSession((session) => { session.context[i] = prompt; }); }; return (
{props.showToast && (
props.setShowModal(true)} > {Locale.Context.Toast(context.length)}
)} {props.showModal && (
props.setShowModal(false)} actions={[ } bordered text={Locale.Memory.Copy} onClick={() => copyToClipboard(session.memoryPrompt)} />, ]} > <> {" "}
{context.map((c, i) => (
updateContextPrompt(i, { ...c, content: e.target.value as any, }) } > } className={chatStyle["context-delete-button"]} onClick={() => removeContextPrompt(i)} bordered />
))}
} text={Locale.Context.Add} bordered className={chatStyle["context-prompt-button"]} onClick={() => addContextPrompt({ role: "system", content: "", date: "", }) } />
{Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "} {session.messages.length})
{session.memoryPrompt || Locale.Memory.EmptyContent}
)}
); } 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}
))}
); } function useScrollToBottom() { // for auto-scroll const scrollRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); // auto scroll useLayoutEffect(() => { const dom = scrollRef.current; if (dom && autoScroll) { setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1); } }); return { scrollRef, autoScroll, setAutoScroll, }; } 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(); const { scrollRef, setAutoScroll } = useScrollToBottom(); const [hitBottom, setHitBottom] = useState(false); const onChatBodyScroll = (e: HTMLElement) => { const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20; setHitBottom(isTouchBottom); }; // 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("/")) { let searchText = text.slice(1); if (searchText.length === 0) { searchText = " "; } onSearch(searchText); } } }; // submit user input const onUserSubmit = () => { if (userInput.length <= 0) return; setIsLoading(true); chatStore.onUserInput(userInput).then(() => setIsLoading(false)); setUserInput(""); setPromptHints([]); if (!isMobileScreen()) inputRef.current?.focus(); setAutoScroll(true); }; // stop response const onUserStop = (messageIndex: number) => { 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; } } }; const config = useChatStore((state) => state.config); const context: RenderMessage[] = session.context.slice(); if ( context.length === 0 && session.messages.at(0)?.content !== BOT_HELLO.content ) { context.push(BOT_HELLO); } // preview messages const messages = context .concat(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: true, }, ] : [], ); const [showPromptModal, setShowPromptModal] = useState(false); // Auto focus useEffect(() => { if (props.sideBarShowing && isMobileScreen()) return; inputRef.current?.focus(); }, []); 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={() => { setShowPromptModal(true); }} />
} bordered title={Locale.Chat.Actions.Export} onClick={() => { exportMessages( session.messages.filter((msg) => !msg.isError), session.topic, ); }} />
onChatBodyScroll(e.currentTarget)} onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)} onTouchStart={() => { inputRef.current?.blur(); setAutoScroll(false); }} > {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()}
)}
); })}