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 RenameIcon from "../icons/rename.svg"; import ExportIcon from "../icons/share.svg"; import ReturnIcon from "../icons/return.svg"; import CopyIcon from "../icons/copy.svg"; import DownloadIcon from "../icons/download.svg"; import LoadingIcon from "../icons/three-dots.svg"; import PromptIcon from "../icons/prompt.svg"; import MaskIcon from "../icons/mask.svg"; import MaxIcon from "../icons/max.svg"; import MinIcon from "../icons/min.svg"; import ResetIcon from "../icons/reload.svg"; import LightIcon from "../icons/light.svg"; import DarkIcon from "../icons/dark.svg"; import AutoIcon from "../icons/auto.svg"; import BottomIcon from "../icons/bottom.svg"; import StopIcon from "../icons/pause.svg"; import { Message, SubmitKey, useChatStore, BOT_HELLO, createMessage, useAccessStore, Theme, useAppConfig, DEFAULT_TOPIC, } from "../store"; import { copyToClipboard, downloadAs, selectOrCopy, autoGrowTextArea, useMobileScreen, } 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 { ListItem, Modal, showModal } from "./ui-lib"; import { useLocation, useNavigate } from "react-router-dom"; import { LAST_INPUT_KEY, Path } from "../constant"; import { Avatar } from "./emoji"; import { MaskAvatar, MaskConfig } from "./mask"; import { useMaskStore } from "../store/mask"; import { useCommand } from "../command"; const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { loading: () => , }); 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)} />, ], }); } export function SessionConfigModel(props: { onClose: () => void }) { const chatStore = useChatStore(); const session = chatStore.currentSession(); const maskStore = useMaskStore(); const navigate = useNavigate(); return (
props.onClose()} actions={[ } bordered text={Locale.Chat.Config.Reset} onClick={() => confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession() } />, } bordered text={Locale.Chat.Config.SaveAs} onClick={() => { navigate(Path.Masks); setTimeout(() => { maskStore.create(session.mask); }, 500); }} />, ]} > { const mask = { ...session.mask }; updater(mask); chatStore.updateCurrentSession((session) => (session.mask = mask)); }} extraListItems={ session.mask.modelConfig.sendMemory ? ( ) : ( <> ) } >
); } function PromptToast(props: { showToast?: boolean; showModal?: boolean; setShowModal: (_: boolean) => void; }) { const chatStore = useChatStore(); const session = chatStore.currentSession(); const context = session.mask.context; return (
{props.showToast && (
props.setShowModal(true)} > {Locale.Context.Toast(context.length)}
)} {props.showModal && ( props.setShowModal(false)} /> )}
); } function useSubmitHandler() { const config = useAppConfig(); 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; }) { const noPrompts = props.prompts.length === 0; const [selectIndex, setSelectIndex] = useState(0); const selectedRef = useRef(null); useEffect(() => { setSelectIndex(0); }, [props.prompts.length]); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (noPrompts) return; if (e.metaKey || e.altKey || e.ctrlKey) { return; } // arrow up / down to select prompt const changeIndex = (delta: number) => { e.stopPropagation(); e.preventDefault(); const nextIndex = Math.max( 0, Math.min(props.prompts.length - 1, selectIndex + delta), ); setSelectIndex(nextIndex); selectedRef.current?.scrollIntoView({ block: "center", }); }; if (e.key === "ArrowUp") { changeIndex(1); } else if (e.key === "ArrowDown") { changeIndex(-1); } else if (e.key === "Enter") { const selectedPrompt = props.prompts.at(selectIndex); if (selectedPrompt) { props.onPromptSelect(selectedPrompt); } } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); // eslint-disable-next-line react-hooks/exhaustive-deps }, [noPrompts, selectIndex]); if (noPrompts) return null; return (
{props.prompts.map((prompt, i) => (
props.onPromptSelect(prompt)} onMouseEnter={() => setSelectIndex(i)} >
{prompt.title}
{prompt.content}
))}
); } function useScrollToBottom() { // for auto-scroll const scrollRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); const scrollToBottom = () => { const dom = scrollRef.current; if (dom) { setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1); } }; // auto scroll useLayoutEffect(() => { autoScroll && scrollToBottom(); }); return { scrollRef, autoScroll, setAutoScroll, scrollToBottom, }; } export function ChatActions(props: { showPromptModal: () => void; scrollToBottom: () => void; showPromptHints: () => void; hitBottom: boolean; }) { const config = useAppConfig(); const navigate = useNavigate(); // switch themes const theme = config.theme; function nextTheme() { const themes = [Theme.Auto, Theme.Light, Theme.Dark]; const themeIndex = themes.indexOf(theme); const nextIndex = (themeIndex + 1) % themes.length; const nextTheme = themes[nextIndex]; config.update((config) => (config.theme = nextTheme)); } // stop all responses const couldStop = ControllerPool.hasPending(); const stopAll = () => ControllerPool.stopAll(); return (
{couldStop && (
)} {!props.hitBottom && (
)} {props.hitBottom && (
)}
{theme === Theme.Auto ? ( ) : theme === Theme.Light ? ( ) : theme === Theme.Dark ? ( ) : null}
{ navigate(Path.Masks); }} >
); } export function Chat() { type RenderMessage = Message & { preview?: boolean }; const chatStore = useChatStore(); const [session, sessionIndex] = useChatStore((state) => [ state.currentSession(), state.currentSessionIndex, ]); const config = useAppConfig(); const fontSize = config.fontSize; const inputRef = useRef(null); const [userInput, setUserInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const { submitKey, shouldSubmit } = useSubmitHandler(); const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom(); const [hitBottom, setHitBottom] = useState(true); const isMobileScreen = useMobileScreen(); const navigate = useNavigate(); const onChatBodyScroll = (e: HTMLElement) => { const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 100; 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) => { setPromptHints([]); inputRef.current?.focus(); setTimeout(() => setUserInput(prompt.content), 60); }; // auto grow input const [inputRows, setInputRows] = useState(2); const measure = useDebouncedCallback( () => { const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1; const inputRows = Math.min( 20, Math.max(2 + Number(!isMobileScreen), rows), ); setInputRows(inputRows); }, 100, { leading: true, trailing: true, }, ); // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(measure, [userInput]); // only search prompts when user input is short const SEARCH_TEXT_LIMIT = 30; const onInput = (text: string) => { setUserInput(text); const n = text.trim().length; // clear search results if (n === 0) { setPromptHints([]); } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { // check if need to trigger auto completion if (text.startsWith("/")) { let searchText = text.slice(1); onSearch(searchText); } } }; const doSubmit = (userInput: string) => { if (userInput.trim() === "") return; setIsLoading(true); chatStore.onUserInput(userInput).then(() => setIsLoading(false)); localStorage.setItem(LAST_INPUT_KEY, userInput); setUserInput(""); setPromptHints([]); if (!isMobileScreen) inputRef.current?.focus(); setAutoScroll(true); }; // stop response const onUserStop = (messageId: number) => { ControllerPool.stop(sessionIndex, messageId); }; // check if should send message const onInputKeyDown = (e: React.KeyboardEvent) => { // if ArrowUp and no userInput, fill with last input if ( e.key === "ArrowUp" && userInput.length <= 0 && !(e.metaKey || e.altKey || e.ctrlKey) ) { setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? ""); e.preventDefault(); return; } if (shouldSubmit(e)) { doSubmit(userInput); e.preventDefault(); } }; const onRightClick = (e: any, message: Message) => { // copy to clipboard if (selectOrCopy(e.currentTarget, message.content)) { e.preventDefault(); } }; const findLastUserIndex = (messageId: number) => { // find last user input message and resend let lastUserMessageIndex: number | null = null; for (let i = 0; i < session.messages.length; i += 1) { const message = session.messages[i]; if (message.id === messageId) { break; } if (message.role === "user") { lastUserMessageIndex = i; } } return lastUserMessageIndex; }; const deleteMessage = (userIndex: number) => { chatStore.updateCurrentSession((session) => session.messages.splice(userIndex, 2), ); }; const onDelete = (botMessageId: number) => { const userIndex = findLastUserIndex(botMessageId); if (userIndex === null) return; deleteMessage(userIndex); }; const onResend = (botMessageId: number) => { // find last user input message and resend const userIndex = findLastUserIndex(botMessageId); if (userIndex === null) return; setIsLoading(true); const content = session.messages[userIndex].content; deleteMessage(userIndex); chatStore.onUserInput(content).then(() => setIsLoading(false)); inputRef.current?.focus(); }; const context: RenderMessage[] = session.mask.context.slice(); const accessStore = useAccessStore(); if ( context.length === 0 && session.messages.at(0)?.content !== BOT_HELLO.content ) { const copiedHello = Object.assign({}, BOT_HELLO); if (!accessStore.isAuthorized()) { copiedHello.content = Locale.Error.Unauthorized; } context.push(copiedHello); } // preview messages const messages = context .concat(session.messages as RenderMessage[]) .concat( isLoading ? [ { ...createMessage({ role: "assistant", content: "……", }), preview: true, }, ] : [], ) .concat( userInput.length > 0 && config.sendPreviewBubble ? [ { ...createMessage({ role: "user", content: userInput, }), preview: true, }, ] : [], ); const [showPromptModal, setShowPromptModal] = useState(false); const renameSession = () => { const newTopic = prompt(Locale.Chat.Rename, session.topic); if (newTopic && newTopic !== session.topic) { chatStore.updateCurrentSession((session) => (session.topic = newTopic!)); } }; const location = useLocation(); const isChat = location.pathname === Path.Chat; const autoFocus = !isMobileScreen || isChat; // only focus in chat page useCommand({ fill: setUserInput, submit: (text) => { doSubmit(text); }, }); return (
{!session.topic ? DEFAULT_TOPIC : session.topic}
{Locale.Chat.SubTitle(session.messages.length)}
} bordered title={Locale.Chat.Actions.ChatList} onClick={() => navigate(Path.Home)} />
} bordered onClick={renameSession} />
} bordered title={Locale.Chat.Actions.Export} onClick={() => { exportMessages( session.messages.filter((msg) => !msg.isError), session.topic, ); }} />
{!isMobileScreen && (
: } bordered onClick={() => { config.update( (config) => (config.tightBorder = !config.tightBorder), ); }} />
)}
onChatBodyScroll(e.currentTarget)} onMouseDown={() => inputRef.current?.blur()} onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)} onTouchStart={() => { inputRef.current?.blur(); setAutoScroll(false); }} > {messages.map((message, i) => { const isUser = message.role === "user"; const showActions = !isUser && i > 0 && !(message.preview || message.content.length === 0); const showTyping = message.preview || message.streaming; return (
{message.role === "user" ? ( ) : ( )}
{showTyping && (
{Locale.Chat.Typing}
)}
{showActions && (
{message.streaming ? (
onUserStop(message.id ?? i)} > {Locale.Chat.Actions.Stop}
) : ( <>
onDelete(message.id ?? i)} > {Locale.Chat.Actions.Delete}
onResend(message.id ?? i)} > {Locale.Chat.Actions.Retry}
)}
copyToClipboard(message.content)} > {Locale.Chat.Actions.Copy}
)} onRightClick(e, message)} onDoubleClickCapture={() => { if (!isMobileScreen) return; setUserInput(message.content); }} fontSize={fontSize} parentRef={scrollRef} defaultShow={i >= messages.length - 10} />
{!isUser && !message.preview && (
{message.date.toLocaleString()}
)}
); })}
setShowPromptModal(true)} scrollToBottom={scrollToBottom} hitBottom={hitBottom} showPromptHints={() => { // Click again to close if (promptHints.length > 0) { setPromptHints([]); return; } inputRef.current?.focus(); setUserInput("/"); onSearch(""); }} />