Spaces:
Running
Running
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, | |
ROLES, | |
createMessage, | |
useAccessStore, | |
Theme, | |
useAppConfig, | |
ModelConfig, | |
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 { Path } from "../constant"; | |
import { Avatar } from "./emoji"; | |
import { MaskAvatar, MaskConfig } from "./mask"; | |
import { | |
DEFAULT_MASK_AVATAR, | |
DEFAULT_MASK_ID, | |
useMaskStore, | |
} from "../store/mask"; | |
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | |
loading: () => <LoadingIcon />, | |
}); | |
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: ( | |
<div className="markdown-body"> | |
<pre className={styles["export-content"]}>{mdText}</pre> | |
</div> | |
), | |
actions: [ | |
<IconButton | |
key="copy" | |
icon={<CopyIcon />} | |
bordered | |
text={Locale.Export.Copy} | |
onClick={() => copyToClipboard(mdText)} | |
/>, | |
<IconButton | |
key="download" | |
icon={<DownloadIcon />} | |
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 ( | |
<div className="modal-mask"> | |
<Modal | |
title={Locale.Context.Edit} | |
onClose={() => props.onClose()} | |
actions={[ | |
<IconButton | |
key="reset" | |
icon={<ResetIcon />} | |
bordered | |
text={Locale.Chat.Config.Reset} | |
onClick={() => | |
confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession() | |
} | |
/>, | |
<IconButton | |
key="copy" | |
icon={<CopyIcon />} | |
bordered | |
text={Locale.Chat.Config.SaveAs} | |
onClick={() => { | |
navigate(Path.Masks); | |
setTimeout(() => { | |
maskStore.create(session.mask); | |
}, 500); | |
}} | |
/>, | |
]} | |
> | |
<MaskConfig | |
mask={session.mask} | |
updateMask={(updater) => { | |
const mask = { ...session.mask }; | |
updater(mask); | |
chatStore.updateCurrentSession((session) => (session.mask = mask)); | |
}} | |
extraListItems={ | |
session.mask.modelConfig.sendMemory ? ( | |
<ListItem | |
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`} | |
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent} | |
></ListItem> | |
) : ( | |
<></> | |
) | |
} | |
></MaskConfig> | |
</Modal> | |
</div> | |
); | |
} | |
function PromptToast(props: { | |
showToast?: boolean; | |
showModal?: boolean; | |
setShowModal: (_: boolean) => void; | |
}) { | |
const chatStore = useChatStore(); | |
const session = chatStore.currentSession(); | |
const context = session.mask.context; | |
return ( | |
<div className={chatStyle["prompt-toast"]} key="prompt-toast"> | |
{props.showToast && ( | |
<div | |
className={chatStyle["prompt-toast-inner"] + " clickable"} | |
role="button" | |
onClick={() => props.setShowModal(true)} | |
> | |
<BrainIcon /> | |
<span className={chatStyle["prompt-toast-content"]}> | |
{Locale.Context.Toast(context.length)} | |
</span> | |
</div> | |
)} | |
{props.showModal && ( | |
<SessionConfigModel onClose={() => props.setShowModal(false)} /> | |
)} | |
</div> | |
); | |
} | |
function useSubmitHandler() { | |
const config = useAppConfig(); | |
const submitKey = config.submitKey; | |
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
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<HTMLDivElement>(null); | |
useEffect(() => { | |
setSelectIndex(0); | |
}, [props.prompts.length]); | |
useEffect(() => { | |
const onKeyDown = (e: KeyboardEvent) => { | |
if (noPrompts) 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 ( | |
<div className={styles["prompt-hints"]}> | |
{props.prompts.map((prompt, i) => ( | |
<div | |
ref={i === selectIndex ? selectedRef : null} | |
className={ | |
styles["prompt-hint"] + | |
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}` | |
} | |
key={prompt.title + i.toString()} | |
onClick={() => props.onPromptSelect(prompt)} | |
onMouseEnter={() => setSelectIndex(i)} | |
> | |
<div className={styles["hint-title"]}>{prompt.title}</div> | |
<div className={styles["hint-content"]}>{prompt.content}</div> | |
</div> | |
))} | |
</div> | |
); | |
} | |
function useScrollToBottom() { | |
// for auto-scroll | |
const scrollRef = useRef<HTMLDivElement>(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 ( | |
<div className={chatStyle["chat-input-actions"]}> | |
{couldStop && ( | |
<div | |
className={`${chatStyle["chat-input-action"]} clickable`} | |
onClick={stopAll} | |
> | |
<StopIcon /> | |
</div> | |
)} | |
{!props.hitBottom && ( | |
<div | |
className={`${chatStyle["chat-input-action"]} clickable`} | |
onClick={props.scrollToBottom} | |
> | |
<BottomIcon /> | |
</div> | |
)} | |
{props.hitBottom && ( | |
<div | |
className={`${chatStyle["chat-input-action"]} clickable`} | |
onClick={props.showPromptModal} | |
> | |
<BrainIcon /> | |
</div> | |
)} | |
<div | |
className={`${chatStyle["chat-input-action"]} clickable`} | |
onClick={nextTheme} | |
> | |
{theme === Theme.Auto ? ( | |
<AutoIcon /> | |
) : theme === Theme.Light ? ( | |
<LightIcon /> | |
) : theme === Theme.Dark ? ( | |
<DarkIcon /> | |
) : null} | |
</div> | |
<div | |
className={`${chatStyle["chat-input-action"]} clickable`} | |
onClick={props.showPromptHints} | |
> | |
<PromptIcon /> | |
</div> | |
<div | |
className={`${chatStyle["chat-input-action"]} clickable`} | |
onClick={() => { | |
navigate(Path.Masks); | |
}} | |
> | |
<MaskIcon /> | |
</div> | |
</div> | |
); | |
} | |
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<HTMLTextAreaElement>(null); | |
const [userInput, setUserInput] = useState(""); | |
const [beforeInput, setBeforeInput] = 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<Prompt[]>([]); | |
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); | |
} | |
} | |
}; | |
// submit user input | |
const onUserSubmit = () => { | |
if (userInput.trim() === "") return; | |
setIsLoading(true); | |
chatStore.onUserInput(userInput).then(() => setIsLoading(false)); | |
setBeforeInput(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<HTMLTextAreaElement>) => { | |
// if ArrowUp and no userInput | |
if (e.key === "ArrowUp" && userInput.length <= 0) { | |
setUserInput(beforeInput); | |
e.preventDefault(); | |
return; | |
} | |
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 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 | |
return ( | |
<div className={styles.chat} key={session.id}> | |
<div className="window-header"> | |
<div className="window-header-title"> | |
<div | |
className={`window-header-main-title " ${styles["chat-body-title"]}`} | |
onClickCapture={renameSession} | |
> | |
{!session.topic ? DEFAULT_TOPIC : session.topic} | |
</div> | |
<div className="window-header-sub-title"> | |
{Locale.Chat.SubTitle(session.messages.length)} | |
</div> | |
</div> | |
<div className="window-actions"> | |
<div className={"window-action-button" + " " + styles.mobile}> | |
<IconButton | |
icon={<ReturnIcon />} | |
bordered | |
title={Locale.Chat.Actions.ChatList} | |
onClick={() => navigate(Path.Home)} | |
/> | |
</div> | |
<div className="window-action-button"> | |
<IconButton | |
icon={<RenameIcon />} | |
bordered | |
onClick={renameSession} | |
/> | |
</div> | |
<div className="window-action-button"> | |
<IconButton | |
icon={<ExportIcon />} | |
bordered | |
title={Locale.Chat.Actions.Export} | |
onClick={() => { | |
exportMessages( | |
session.messages.filter((msg) => !msg.isError), | |
session.topic, | |
); | |
}} | |
/> | |
</div> | |
{!isMobileScreen && ( | |
<div className="window-action-button"> | |
<IconButton | |
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />} | |
bordered | |
onClick={() => { | |
config.update( | |
(config) => (config.tightBorder = !config.tightBorder), | |
); | |
}} | |
/> | |
</div> | |
)} | |
</div> | |
<PromptToast | |
showToast={!hitBottom} | |
showModal={showPromptModal} | |
setShowModal={setShowPromptModal} | |
/> | |
</div> | |
<div | |
className={styles["chat-body"]} | |
ref={scrollRef} | |
onScroll={(e) => 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 ( | |
<div | |
key={i} | |
className={ | |
isUser ? styles["chat-message-user"] : styles["chat-message"] | |
} | |
> | |
<div className={styles["chat-message-container"]}> | |
<div className={styles["chat-message-avatar"]}> | |
{message.role === "user" ? ( | |
<Avatar avatar={config.avatar} /> | |
) : ( | |
<MaskAvatar mask={session.mask} /> | |
)} | |
</div> | |
{showTyping && ( | |
<div className={styles["chat-message-status"]}> | |
{Locale.Chat.Typing} | |
</div> | |
)} | |
<div className={styles["chat-message-item"]}> | |
{showActions && ( | |
<div className={styles["chat-message-top-actions"]}> | |
{message.streaming ? ( | |
<div | |
className={styles["chat-message-top-action"]} | |
onClick={() => onUserStop(message.id ?? i)} | |
> | |
{Locale.Chat.Actions.Stop} | |
</div> | |
) : ( | |
<> | |
<div | |
className={styles["chat-message-top-action"]} | |
onClick={() => onDelete(message.id ?? i)} | |
> | |
{Locale.Chat.Actions.Delete} | |
</div> | |
<div | |
className={styles["chat-message-top-action"]} | |
onClick={() => onResend(message.id ?? i)} | |
> | |
{Locale.Chat.Actions.Retry} | |
</div> | |
</> | |
)} | |
<div | |
className={styles["chat-message-top-action"]} | |
onClick={() => copyToClipboard(message.content)} | |
> | |
{Locale.Chat.Actions.Copy} | |
</div> | |
</div> | |
)} | |
<Markdown | |
content={message.content} | |
loading={ | |
(message.preview || message.content.length === 0) && | |
!isUser | |
} | |
onContextMenu={(e) => onRightClick(e, message)} | |
onDoubleClickCapture={() => { | |
if (!isMobileScreen) return; | |
setUserInput(message.content); | |
}} | |
fontSize={fontSize} | |
parentRef={scrollRef} | |
defaultShow={i >= messages.length - 10} | |
/> | |
</div> | |
{!isUser && !message.preview && ( | |
<div className={styles["chat-message-actions"]}> | |
<div className={styles["chat-message-action-date"]}> | |
{message.date.toLocaleString()} | |
</div> | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
})} | |
</div> | |
<div className={styles["chat-input-panel"]}> | |
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} /> | |
<ChatActions | |
showPromptModal={() => setShowPromptModal(true)} | |
scrollToBottom={scrollToBottom} | |
hitBottom={hitBottom} | |
showPromptHints={() => { | |
inputRef.current?.focus(); | |
onSearch(""); | |
}} | |
/> | |
<div className={styles["chat-input-panel-inner"]}> | |
<textarea | |
ref={inputRef} | |
className={styles["chat-input"]} | |
placeholder={Locale.Chat.Input(submitKey)} | |
onInput={(e) => onInput(e.currentTarget.value)} | |
value={userInput} | |
onKeyDown={onInputKeyDown} | |
onFocus={() => setAutoScroll(true)} | |
onBlur={() => setAutoScroll(false)} | |
rows={inputRows} | |
autoFocus={autoFocus} | |
/> | |
<IconButton | |
icon={<SendWhiteIcon />} | |
text={Locale.Chat.Send} | |
className={styles["chat-input-send"]} | |
type="primary" | |
onClick={onUserSubmit} | |
/> | |
</div> | |
</div> | |
</div> | |
); | |
} | |