chat.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. import { useDebouncedCallback } from "use-debounce";
  2. import { useState, useRef, useEffect, useLayoutEffect } from "react";
  3. import SendWhiteIcon from "../icons/send-white.svg";
  4. import BrainIcon from "../icons/brain.svg";
  5. import ExportIcon from "../icons/export.svg";
  6. import MenuIcon from "../icons/menu.svg";
  7. import CopyIcon from "../icons/copy.svg";
  8. import DownloadIcon from "../icons/download.svg";
  9. import LoadingIcon from "../icons/three-dots.svg";
  10. import BotIcon from "../icons/bot.svg";
  11. import {
  12. Message,
  13. SubmitKey,
  14. useChatStore,
  15. ChatSession,
  16. BOT_HELLO,
  17. } from "../store";
  18. import {
  19. copyToClipboard,
  20. downloadAs,
  21. isMobileScreen,
  22. selectOrCopy,
  23. } from "../utils";
  24. import dynamic from "next/dynamic";
  25. import { ControllerPool } from "../requests";
  26. import { Prompt, usePromptStore } from "../store/prompt";
  27. import Locale from "../locales";
  28. import { IconButton } from "./button";
  29. import styles from "./home.module.scss";
  30. import { showModal, showToast } from "./ui-lib";
  31. const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
  32. loading: () => <LoadingIcon />,
  33. });
  34. const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
  35. loading: () => <LoadingIcon />,
  36. });
  37. export function Avatar(props: { role: Message["role"] }) {
  38. const config = useChatStore((state) => state.config);
  39. if (props.role === "assistant") {
  40. return <BotIcon className={styles["user-avtar"]} />;
  41. }
  42. return (
  43. <div className={styles["user-avtar"]}>
  44. <Emoji unified={config.avatar} size={18} />
  45. </div>
  46. );
  47. }
  48. function exportMessages(messages: Message[], topic: string) {
  49. const mdText =
  50. `# ${topic}\n\n` +
  51. messages
  52. .map((m) => {
  53. return m.role === "user" ? `## ${m.content}` : m.content.trim();
  54. })
  55. .join("\n\n");
  56. const filename = `${topic}.md`;
  57. showModal({
  58. title: Locale.Export.Title,
  59. children: (
  60. <div className="markdown-body">
  61. <pre className={styles["export-content"]}>{mdText}</pre>
  62. </div>
  63. ),
  64. actions: [
  65. <IconButton
  66. key="copy"
  67. icon={<CopyIcon />}
  68. bordered
  69. text={Locale.Export.Copy}
  70. onClick={() => copyToClipboard(mdText)}
  71. />,
  72. <IconButton
  73. key="download"
  74. icon={<DownloadIcon />}
  75. bordered
  76. text={Locale.Export.Download}
  77. onClick={() => downloadAs(mdText, filename)}
  78. />,
  79. ],
  80. });
  81. }
  82. function showMemoryPrompt(session: ChatSession) {
  83. showModal({
  84. title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`,
  85. children: (
  86. <div className="markdown-body">
  87. <pre className={styles["export-content"]}>
  88. {session.memoryPrompt || Locale.Memory.EmptyContent}
  89. </pre>
  90. </div>
  91. ),
  92. actions: [
  93. <IconButton
  94. key="copy"
  95. icon={<CopyIcon />}
  96. bordered
  97. text={Locale.Memory.Copy}
  98. onClick={() => copyToClipboard(session.memoryPrompt)}
  99. />,
  100. ],
  101. });
  102. }
  103. function useSubmitHandler() {
  104. const config = useChatStore((state) => state.config);
  105. const submitKey = config.submitKey;
  106. const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  107. if (e.key !== "Enter") return false;
  108. if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
  109. return (
  110. (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
  111. (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
  112. (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
  113. (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
  114. (config.submitKey === SubmitKey.Enter &&
  115. !e.altKey &&
  116. !e.ctrlKey &&
  117. !e.shiftKey &&
  118. !e.metaKey)
  119. );
  120. };
  121. return {
  122. submitKey,
  123. shouldSubmit,
  124. };
  125. }
  126. export function PromptHints(props: {
  127. prompts: Prompt[];
  128. onPromptSelect: (prompt: Prompt) => void;
  129. }) {
  130. if (props.prompts.length === 0) return null;
  131. return (
  132. <div className={styles["prompt-hints"]}>
  133. {props.prompts.map((prompt, i) => (
  134. <div
  135. className={styles["prompt-hint"]}
  136. key={prompt.title + i.toString()}
  137. onClick={() => props.onPromptSelect(prompt)}
  138. >
  139. <div className={styles["hint-title"]}>{prompt.title}</div>
  140. <div className={styles["hint-content"]}>{prompt.content}</div>
  141. </div>
  142. ))}
  143. </div>
  144. );
  145. }
  146. export function Chat(props: {
  147. showSideBar?: () => void;
  148. sideBarShowing?: boolean;
  149. }) {
  150. type RenderMessage = Message & { preview?: boolean };
  151. const chatStore = useChatStore();
  152. const [session, sessionIndex] = useChatStore((state) => [
  153. state.currentSession(),
  154. state.currentSessionIndex,
  155. ]);
  156. const fontSize = useChatStore((state) => state.config.fontSize);
  157. const inputRef = useRef<HTMLTextAreaElement>(null);
  158. const [userInput, setUserInput] = useState("");
  159. const [isLoading, setIsLoading] = useState(false);
  160. const { submitKey, shouldSubmit } = useSubmitHandler();
  161. // prompt hints
  162. const promptStore = usePromptStore();
  163. const [promptHints, setPromptHints] = useState<Prompt[]>([]);
  164. const onSearch = useDebouncedCallback(
  165. (text: string) => {
  166. setPromptHints(promptStore.search(text));
  167. },
  168. 100,
  169. { leading: true, trailing: true },
  170. );
  171. const onPromptSelect = (prompt: Prompt) => {
  172. setUserInput(prompt.content);
  173. setPromptHints([]);
  174. inputRef.current?.focus();
  175. };
  176. const scrollInput = () => {
  177. const dom = inputRef.current;
  178. if (!dom) return;
  179. const paddingBottomNum: number = parseInt(
  180. window.getComputedStyle(dom).paddingBottom,
  181. 10,
  182. );
  183. dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum;
  184. };
  185. // only search prompts when user input is short
  186. const SEARCH_TEXT_LIMIT = 30;
  187. const onInput = (text: string) => {
  188. scrollInput();
  189. setUserInput(text);
  190. const n = text.trim().length;
  191. // clear search results
  192. if (n === 0) {
  193. setPromptHints([]);
  194. } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
  195. // check if need to trigger auto completion
  196. if (text.startsWith("/") && text.length > 1) {
  197. onSearch(text.slice(1));
  198. }
  199. }
  200. };
  201. // submit user input
  202. const onUserSubmit = () => {
  203. if (userInput.length <= 0) return;
  204. setIsLoading(true);
  205. chatStore.onUserInput(userInput).then(() => setIsLoading(false));
  206. setUserInput("");
  207. setPromptHints([]);
  208. inputRef.current?.focus();
  209. };
  210. // stop response
  211. const onUserStop = (messageIndex: number) => {
  212. console.log(ControllerPool, sessionIndex, messageIndex);
  213. ControllerPool.stop(sessionIndex, messageIndex);
  214. };
  215. // check if should send message
  216. const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  217. if (shouldSubmit(e)) {
  218. onUserSubmit();
  219. e.preventDefault();
  220. }
  221. };
  222. const onRightClick = (e: any, message: Message) => {
  223. // auto fill user input
  224. if (message.role === "user") {
  225. setUserInput(message.content);
  226. }
  227. // copy to clipboard
  228. if (selectOrCopy(e.currentTarget, message.content)) {
  229. e.preventDefault();
  230. }
  231. };
  232. const onResend = (botIndex: number) => {
  233. // find last user input message and resend
  234. for (let i = botIndex; i >= 0; i -= 1) {
  235. if (messages[i].role === "user") {
  236. setIsLoading(true);
  237. chatStore
  238. .onUserInput(messages[i].content)
  239. .then(() => setIsLoading(false));
  240. inputRef.current?.focus();
  241. return;
  242. }
  243. }
  244. };
  245. // for auto-scroll
  246. const latestMessageRef = useRef<HTMLDivElement>(null);
  247. const [autoScroll, setAutoScroll] = useState(true);
  248. const config = useChatStore((state) => state.config);
  249. // preview messages
  250. const messages = (session.messages as RenderMessage[])
  251. .concat(
  252. isLoading
  253. ? [
  254. {
  255. role: "assistant",
  256. content: "……",
  257. date: new Date().toLocaleString(),
  258. preview: true,
  259. },
  260. ]
  261. : [],
  262. )
  263. .concat(
  264. userInput.length > 0 && config.sendPreviewBubble
  265. ? [
  266. {
  267. role: "user",
  268. content: userInput,
  269. date: new Date().toLocaleString(),
  270. preview: false,
  271. },
  272. ]
  273. : [],
  274. );
  275. // auto scroll
  276. useLayoutEffect(() => {
  277. setTimeout(() => {
  278. const dom = latestMessageRef.current;
  279. const inputDom = inputRef.current;
  280. // only scroll when input overlaped message body
  281. let shouldScroll = true;
  282. if (dom && inputDom) {
  283. const domRect = dom.getBoundingClientRect();
  284. const inputRect = inputDom.getBoundingClientRect();
  285. shouldScroll = domRect.top > inputRect.top;
  286. }
  287. if (dom && autoScroll && shouldScroll) {
  288. dom.scrollIntoView({
  289. block: "end",
  290. });
  291. }
  292. }, 500);
  293. });
  294. return (
  295. <div className={styles.chat} key={session.id}>
  296. <div className={styles["window-header"]}>
  297. <div
  298. className={styles["window-header-title"]}
  299. onClick={props?.showSideBar}
  300. >
  301. <div
  302. className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
  303. onClick={() => {
  304. const newTopic = prompt(Locale.Chat.Rename, session.topic);
  305. if (newTopic && newTopic !== session.topic) {
  306. chatStore.updateCurrentSession(
  307. (session) => (session.topic = newTopic!),
  308. );
  309. }
  310. }}
  311. >
  312. {session.topic}
  313. </div>
  314. <div className={styles["window-header-sub-title"]}>
  315. {Locale.Chat.SubTitle(session.messages.length)}
  316. </div>
  317. </div>
  318. <div className={styles["window-actions"]}>
  319. <div className={styles["window-action-button"] + " " + styles.mobile}>
  320. <IconButton
  321. icon={<MenuIcon />}
  322. bordered
  323. title={Locale.Chat.Actions.ChatList}
  324. onClick={props?.showSideBar}
  325. />
  326. </div>
  327. <div className={styles["window-action-button"]}>
  328. <IconButton
  329. icon={<BrainIcon />}
  330. bordered
  331. title={Locale.Chat.Actions.CompressedHistory}
  332. onClick={() => {
  333. showMemoryPrompt(session);
  334. }}
  335. />
  336. </div>
  337. <div className={styles["window-action-button"]}>
  338. <IconButton
  339. icon={<ExportIcon />}
  340. bordered
  341. title={Locale.Chat.Actions.Export}
  342. onClick={() => {
  343. exportMessages(session.messages, session.topic);
  344. }}
  345. />
  346. </div>
  347. </div>
  348. </div>
  349. <div className={styles["chat-body"]}>
  350. {messages.map((message, i) => {
  351. const isUser = message.role === "user";
  352. return (
  353. <div
  354. key={i}
  355. className={
  356. isUser ? styles["chat-message-user"] : styles["chat-message"]
  357. }
  358. >
  359. <div className={styles["chat-message-container"]}>
  360. <div className={styles["chat-message-avatar"]}>
  361. <Avatar role={message.role} />
  362. </div>
  363. {(message.preview || message.streaming) && (
  364. <div className={styles["chat-message-status"]}>
  365. {Locale.Chat.Typing}
  366. </div>
  367. )}
  368. <div className={styles["chat-message-item"]}>
  369. {!isUser &&
  370. !(message.preview || message.content.length === 0) && (
  371. <div className={styles["chat-message-top-actions"]}>
  372. {message.streaming ? (
  373. <div
  374. className={styles["chat-message-top-action"]}
  375. onClick={() => onUserStop(i)}
  376. >
  377. {Locale.Chat.Actions.Stop}
  378. </div>
  379. ) : (
  380. <div
  381. className={styles["chat-message-top-action"]}
  382. onClick={() => onResend(i)}
  383. >
  384. {Locale.Chat.Actions.Retry}
  385. </div>
  386. )}
  387. <div
  388. className={styles["chat-message-top-action"]}
  389. onClick={() => copyToClipboard(message.content)}
  390. >
  391. {Locale.Chat.Actions.Copy}
  392. </div>
  393. </div>
  394. )}
  395. {(message.preview || message.content.length === 0) &&
  396. !isUser ? (
  397. <LoadingIcon />
  398. ) : (
  399. <div
  400. className="markdown-body"
  401. style={{ fontSize: `${fontSize}px` }}
  402. onContextMenu={(e) => onRightClick(e, message)}
  403. onDoubleClickCapture={() => {
  404. if (!isMobileScreen()) return;
  405. setUserInput(message.content);
  406. }}
  407. >
  408. <Markdown content={message.content} />
  409. </div>
  410. )}
  411. </div>
  412. {!isUser && !message.preview && (
  413. <div className={styles["chat-message-actions"]}>
  414. <div className={styles["chat-message-action-date"]}>
  415. {message.date.toLocaleString()}
  416. </div>
  417. </div>
  418. )}
  419. </div>
  420. </div>
  421. );
  422. })}
  423. <div ref={latestMessageRef} style={{ opacity: 0, height: "1px" }}>
  424. -
  425. </div>
  426. </div>
  427. <div className={styles["chat-input-panel"]}>
  428. <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
  429. <div className={styles["chat-input-panel-inner"]}>
  430. <textarea
  431. ref={inputRef}
  432. className={styles["chat-input"]}
  433. placeholder={Locale.Chat.Input(submitKey)}
  434. rows={4}
  435. onInput={(e) => onInput(e.currentTarget.value)}
  436. value={userInput}
  437. onKeyDown={onInputKeyDown}
  438. onFocus={() => setAutoScroll(true)}
  439. onBlur={() => {
  440. setAutoScroll(false);
  441. setTimeout(() => setPromptHints([]), 500);
  442. }}
  443. autoFocus={!props?.sideBarShowing}
  444. />
  445. <IconButton
  446. icon={<SendWhiteIcon />}
  447. text={Locale.Chat.Send}
  448. className={styles["chat-input-send"] + " no-dark"}
  449. onClick={onUserSubmit}
  450. />
  451. </div>
  452. </div>
  453. </div>
  454. );
  455. }