chat.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882
  1. import { useDebouncedCallback } from "use-debounce";
  2. import { memo, useState, useRef, useEffect, useLayoutEffect } from "react";
  3. import SendWhiteIcon from "../icons/send-white.svg";
  4. import BrainIcon from "../icons/brain.svg";
  5. import RenameIcon from "../icons/rename.svg";
  6. import ExportIcon from "../icons/share.svg";
  7. import ReturnIcon from "../icons/return.svg";
  8. import CopyIcon from "../icons/copy.svg";
  9. import DownloadIcon from "../icons/download.svg";
  10. import LoadingIcon from "../icons/three-dots.svg";
  11. import AddIcon from "../icons/add.svg";
  12. import DeleteIcon from "../icons/delete.svg";
  13. import MaxIcon from "../icons/max.svg";
  14. import MinIcon from "../icons/min.svg";
  15. import LightIcon from "../icons/light.svg";
  16. import DarkIcon from "../icons/dark.svg";
  17. import AutoIcon from "../icons/auto.svg";
  18. import BottomIcon from "../icons/bottom.svg";
  19. import StopIcon from "../icons/pause.svg";
  20. import {
  21. Message,
  22. SubmitKey,
  23. useChatStore,
  24. BOT_HELLO,
  25. ROLES,
  26. createMessage,
  27. useAccessStore,
  28. Theme,
  29. useAppConfig,
  30. ModelConfig,
  31. DEFAULT_TOPIC,
  32. } from "../store";
  33. import {
  34. copyToClipboard,
  35. downloadAs,
  36. selectOrCopy,
  37. autoGrowTextArea,
  38. useMobileScreen,
  39. } from "../utils";
  40. import dynamic from "next/dynamic";
  41. import { ControllerPool } from "../requests";
  42. import { Prompt, usePromptStore } from "../store/prompt";
  43. import Locale from "../locales";
  44. import { IconButton } from "./button";
  45. import styles from "./home.module.scss";
  46. import chatStyle from "./chat.module.scss";
  47. import { Input, List, ListItem, Modal, Popover, showModal } from "./ui-lib";
  48. import { useNavigate } from "react-router-dom";
  49. import { Path } from "../constant";
  50. import { ModelConfigList } from "./model-config";
  51. import { Avatar, AvatarPicker } from "./emoji";
  52. const Markdown = dynamic(
  53. async () => memo((await import("./markdown")).Markdown),
  54. {
  55. loading: () => <LoadingIcon />,
  56. },
  57. );
  58. function exportMessages(messages: Message[], topic: string) {
  59. const mdText =
  60. `# ${topic}\n\n` +
  61. messages
  62. .map((m) => {
  63. return m.role === "user"
  64. ? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
  65. : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
  66. })
  67. .join("\n\n");
  68. const filename = `${topic}.md`;
  69. showModal({
  70. title: Locale.Export.Title,
  71. children: (
  72. <div className="markdown-body">
  73. <pre className={styles["export-content"]}>{mdText}</pre>
  74. </div>
  75. ),
  76. actions: [
  77. <IconButton
  78. key="copy"
  79. icon={<CopyIcon />}
  80. bordered
  81. text={Locale.Export.Copy}
  82. onClick={() => copyToClipboard(mdText)}
  83. />,
  84. <IconButton
  85. key="download"
  86. icon={<DownloadIcon />}
  87. bordered
  88. text={Locale.Export.Download}
  89. onClick={() => downloadAs(mdText, filename)}
  90. />,
  91. ],
  92. });
  93. }
  94. function ContextPrompts() {
  95. const chatStore = useChatStore();
  96. const session = chatStore.currentSession();
  97. const context = session.context;
  98. const addContextPrompt = (prompt: Message) => {
  99. chatStore.updateCurrentSession((session) => {
  100. session.context.push(prompt);
  101. });
  102. };
  103. const removeContextPrompt = (i: number) => {
  104. chatStore.updateCurrentSession((session) => {
  105. session.context.splice(i, 1);
  106. });
  107. };
  108. const updateContextPrompt = (i: number, prompt: Message) => {
  109. chatStore.updateCurrentSession((session) => {
  110. session.context[i] = prompt;
  111. });
  112. };
  113. return (
  114. <>
  115. <div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
  116. {context.map((c, i) => (
  117. <div className={chatStyle["context-prompt-row"]} key={i}>
  118. <select
  119. value={c.role}
  120. className={chatStyle["context-role"]}
  121. onChange={(e) =>
  122. updateContextPrompt(i, {
  123. ...c,
  124. role: e.target.value as any,
  125. })
  126. }
  127. >
  128. {ROLES.map((r) => (
  129. <option key={r} value={r}>
  130. {r}
  131. </option>
  132. ))}
  133. </select>
  134. <Input
  135. value={c.content}
  136. type="text"
  137. className={chatStyle["context-content"]}
  138. rows={1}
  139. onInput={(e) =>
  140. updateContextPrompt(i, {
  141. ...c,
  142. content: e.currentTarget.value as any,
  143. })
  144. }
  145. />
  146. <IconButton
  147. icon={<DeleteIcon />}
  148. className={chatStyle["context-delete-button"]}
  149. onClick={() => removeContextPrompt(i)}
  150. bordered
  151. />
  152. </div>
  153. ))}
  154. <div className={chatStyle["context-prompt-row"]}>
  155. <IconButton
  156. icon={<AddIcon />}
  157. text={Locale.Context.Add}
  158. bordered
  159. className={chatStyle["context-prompt-button"]}
  160. onClick={() =>
  161. addContextPrompt({
  162. role: "system",
  163. content: "",
  164. date: "",
  165. })
  166. }
  167. />
  168. </div>
  169. </div>
  170. </>
  171. );
  172. }
  173. export function SessionConfigModel(props: { onClose: () => void }) {
  174. const chatStore = useChatStore();
  175. const session = chatStore.currentSession();
  176. const [showPicker, setShowPicker] = useState(false);
  177. const updateConfig = (updater: (config: ModelConfig) => void) => {
  178. const config = { ...session.modelConfig };
  179. updater(config);
  180. chatStore.updateCurrentSession((session) => (session.modelConfig = config));
  181. };
  182. return (
  183. <div className="modal-mask">
  184. <Modal
  185. title={Locale.Context.Edit}
  186. onClose={() => props.onClose()}
  187. actions={[
  188. <IconButton
  189. key="reset"
  190. icon={<CopyIcon />}
  191. bordered
  192. text="重置预设"
  193. onClick={() =>
  194. confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession()
  195. }
  196. />,
  197. <IconButton
  198. key="copy"
  199. icon={<CopyIcon />}
  200. bordered
  201. text="保存预设"
  202. onClick={() => copyToClipboard(session.memoryPrompt)}
  203. />,
  204. ]}
  205. >
  206. <ContextPrompts />
  207. <List>
  208. <ListItem title={"角色头像"}>
  209. <Popover
  210. content={
  211. <AvatarPicker
  212. onEmojiClick={(emoji) =>
  213. chatStore.updateCurrentSession(
  214. (session) => (session.avatar = emoji),
  215. )
  216. }
  217. ></AvatarPicker>
  218. }
  219. open={showPicker}
  220. onClose={() => setShowPicker(false)}
  221. >
  222. <div onClick={() => setShowPicker(true)}>
  223. {session.avatar ? (
  224. <Avatar avatar={session.avatar} />
  225. ) : (
  226. <Avatar model={session.modelConfig.model} />
  227. )}
  228. </div>
  229. </Popover>
  230. </ListItem>
  231. <ListItem title={"对话标题"}>
  232. <input
  233. type="text"
  234. value={session.topic}
  235. onInput={(e) =>
  236. chatStore.updateCurrentSession(
  237. (session) => (session.topic = e.currentTarget.value),
  238. )
  239. }
  240. ></input>
  241. </ListItem>
  242. </List>
  243. <List>
  244. <ModelConfigList
  245. modelConfig={session.modelConfig}
  246. updateConfig={updateConfig}
  247. />
  248. {session.modelConfig.sendMemory ? (
  249. <ListItem
  250. title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of
  251. ${session.messages.length})`}
  252. subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
  253. ></ListItem>
  254. ) : (
  255. <></>
  256. )}
  257. </List>
  258. </Modal>
  259. </div>
  260. );
  261. }
  262. function PromptToast(props: {
  263. showToast?: boolean;
  264. showModal?: boolean;
  265. setShowModal: (_: boolean) => void;
  266. }) {
  267. const chatStore = useChatStore();
  268. const session = chatStore.currentSession();
  269. const context = session.context;
  270. return (
  271. <div className={chatStyle["prompt-toast"]} key="prompt-toast">
  272. {props.showToast && (
  273. <div
  274. className={chatStyle["prompt-toast-inner"] + " clickable"}
  275. role="button"
  276. onClick={() => props.setShowModal(true)}
  277. >
  278. <BrainIcon />
  279. <span className={chatStyle["prompt-toast-content"]}>
  280. {Locale.Context.Toast(context.length)}
  281. </span>
  282. </div>
  283. )}
  284. {props.showModal && (
  285. <SessionConfigModel onClose={() => props.setShowModal(false)} />
  286. )}
  287. </div>
  288. );
  289. }
  290. function useSubmitHandler() {
  291. const config = useAppConfig();
  292. const submitKey = config.submitKey;
  293. const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  294. if (e.key !== "Enter") return false;
  295. if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
  296. return (
  297. (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
  298. (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
  299. (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
  300. (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
  301. (config.submitKey === SubmitKey.Enter &&
  302. !e.altKey &&
  303. !e.ctrlKey &&
  304. !e.shiftKey &&
  305. !e.metaKey)
  306. );
  307. };
  308. return {
  309. submitKey,
  310. shouldSubmit,
  311. };
  312. }
  313. export function PromptHints(props: {
  314. prompts: Prompt[];
  315. onPromptSelect: (prompt: Prompt) => void;
  316. }) {
  317. if (props.prompts.length === 0) return null;
  318. return (
  319. <div className={styles["prompt-hints"]}>
  320. {props.prompts.map((prompt, i) => (
  321. <div
  322. className={styles["prompt-hint"]}
  323. key={prompt.title + i.toString()}
  324. onClick={() => props.onPromptSelect(prompt)}
  325. >
  326. <div className={styles["hint-title"]}>{prompt.title}</div>
  327. <div className={styles["hint-content"]}>{prompt.content}</div>
  328. </div>
  329. ))}
  330. </div>
  331. );
  332. }
  333. function useScrollToBottom() {
  334. // for auto-scroll
  335. const scrollRef = useRef<HTMLDivElement>(null);
  336. const [autoScroll, setAutoScroll] = useState(true);
  337. const scrollToBottom = () => {
  338. const dom = scrollRef.current;
  339. if (dom) {
  340. setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
  341. }
  342. };
  343. // auto scroll
  344. useLayoutEffect(() => {
  345. autoScroll && scrollToBottom();
  346. });
  347. return {
  348. scrollRef,
  349. autoScroll,
  350. setAutoScroll,
  351. scrollToBottom,
  352. };
  353. }
  354. export function ChatActions(props: {
  355. showPromptModal: () => void;
  356. scrollToBottom: () => void;
  357. hitBottom: boolean;
  358. }) {
  359. const config = useAppConfig();
  360. // switch themes
  361. const theme = config.theme;
  362. function nextTheme() {
  363. const themes = [Theme.Auto, Theme.Light, Theme.Dark];
  364. const themeIndex = themes.indexOf(theme);
  365. const nextIndex = (themeIndex + 1) % themes.length;
  366. const nextTheme = themes[nextIndex];
  367. config.update((config) => (config.theme = nextTheme));
  368. }
  369. // stop all responses
  370. const couldStop = ControllerPool.hasPending();
  371. const stopAll = () => ControllerPool.stopAll();
  372. return (
  373. <div className={chatStyle["chat-input-actions"]}>
  374. {couldStop && (
  375. <div
  376. className={`${chatStyle["chat-input-action"]} clickable`}
  377. onClick={stopAll}
  378. >
  379. <StopIcon />
  380. </div>
  381. )}
  382. {!props.hitBottom && (
  383. <div
  384. className={`${chatStyle["chat-input-action"]} clickable`}
  385. onClick={props.scrollToBottom}
  386. >
  387. <BottomIcon />
  388. </div>
  389. )}
  390. {props.hitBottom && (
  391. <div
  392. className={`${chatStyle["chat-input-action"]} clickable`}
  393. onClick={props.showPromptModal}
  394. >
  395. <BrainIcon />
  396. </div>
  397. )}
  398. <div
  399. className={`${chatStyle["chat-input-action"]} clickable`}
  400. onClick={nextTheme}
  401. >
  402. {theme === Theme.Auto ? (
  403. <AutoIcon />
  404. ) : theme === Theme.Light ? (
  405. <LightIcon />
  406. ) : theme === Theme.Dark ? (
  407. <DarkIcon />
  408. ) : null}
  409. </div>
  410. </div>
  411. );
  412. }
  413. export function Chat() {
  414. type RenderMessage = Message & { preview?: boolean };
  415. const chatStore = useChatStore();
  416. const [session, sessionIndex] = useChatStore((state) => [
  417. state.currentSession(),
  418. state.currentSessionIndex,
  419. ]);
  420. const config = useAppConfig();
  421. const fontSize = config.fontSize;
  422. const inputRef = useRef<HTMLTextAreaElement>(null);
  423. const [userInput, setUserInput] = useState("");
  424. const [beforeInput, setBeforeInput] = useState("");
  425. const [isLoading, setIsLoading] = useState(false);
  426. const { submitKey, shouldSubmit } = useSubmitHandler();
  427. const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
  428. const [hitBottom, setHitBottom] = useState(false);
  429. const isMobileScreen = useMobileScreen();
  430. const navigate = useNavigate();
  431. const onChatBodyScroll = (e: HTMLElement) => {
  432. const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20;
  433. setHitBottom(isTouchBottom);
  434. };
  435. // prompt hints
  436. const promptStore = usePromptStore();
  437. const [promptHints, setPromptHints] = useState<Prompt[]>([]);
  438. const onSearch = useDebouncedCallback(
  439. (text: string) => {
  440. setPromptHints(promptStore.search(text));
  441. },
  442. 100,
  443. { leading: true, trailing: true },
  444. );
  445. const onPromptSelect = (prompt: Prompt) => {
  446. setUserInput(prompt.content);
  447. setPromptHints([]);
  448. inputRef.current?.focus();
  449. };
  450. // auto grow input
  451. const [inputRows, setInputRows] = useState(2);
  452. const measure = useDebouncedCallback(
  453. () => {
  454. const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
  455. const inputRows = Math.min(
  456. 5,
  457. Math.max(2 + Number(!isMobileScreen), rows),
  458. );
  459. setInputRows(inputRows);
  460. },
  461. 100,
  462. {
  463. leading: true,
  464. trailing: true,
  465. },
  466. );
  467. // eslint-disable-next-line react-hooks/exhaustive-deps
  468. useEffect(measure, [userInput]);
  469. // only search prompts when user input is short
  470. const SEARCH_TEXT_LIMIT = 30;
  471. const onInput = (text: string) => {
  472. setUserInput(text);
  473. const n = text.trim().length;
  474. // clear search results
  475. if (n === 0) {
  476. setPromptHints([]);
  477. } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
  478. // check if need to trigger auto completion
  479. if (text.startsWith("/")) {
  480. let searchText = text.slice(1);
  481. onSearch(searchText);
  482. }
  483. }
  484. };
  485. // submit user input
  486. const onUserSubmit = () => {
  487. if (userInput.length <= 0) return;
  488. setIsLoading(true);
  489. chatStore.onUserInput(userInput).then(() => setIsLoading(false));
  490. setBeforeInput(userInput);
  491. setUserInput("");
  492. setPromptHints([]);
  493. if (!isMobileScreen) inputRef.current?.focus();
  494. setAutoScroll(true);
  495. };
  496. // stop response
  497. const onUserStop = (messageId: number) => {
  498. ControllerPool.stop(sessionIndex, messageId);
  499. };
  500. // check if should send message
  501. const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  502. // if ArrowUp and no userInput
  503. if (e.key === "ArrowUp" && userInput.length <= 0) {
  504. setUserInput(beforeInput);
  505. e.preventDefault();
  506. return;
  507. }
  508. if (shouldSubmit(e)) {
  509. onUserSubmit();
  510. e.preventDefault();
  511. }
  512. };
  513. const onRightClick = (e: any, message: Message) => {
  514. // auto fill user input
  515. if (message.role === "user") {
  516. setUserInput(message.content);
  517. }
  518. // copy to clipboard
  519. if (selectOrCopy(e.currentTarget, message.content)) {
  520. e.preventDefault();
  521. }
  522. };
  523. const findLastUesrIndex = (messageId: number) => {
  524. // find last user input message and resend
  525. let lastUserMessageIndex: number | null = null;
  526. for (let i = 0; i < session.messages.length; i += 1) {
  527. const message = session.messages[i];
  528. if (message.id === messageId) {
  529. break;
  530. }
  531. if (message.role === "user") {
  532. lastUserMessageIndex = i;
  533. }
  534. }
  535. return lastUserMessageIndex;
  536. };
  537. const deleteMessage = (userIndex: number) => {
  538. chatStore.updateCurrentSession((session) =>
  539. session.messages.splice(userIndex, 2),
  540. );
  541. };
  542. const onDelete = (botMessageId: number) => {
  543. const userIndex = findLastUesrIndex(botMessageId);
  544. if (userIndex === null) return;
  545. deleteMessage(userIndex);
  546. };
  547. const onResend = (botMessageId: number) => {
  548. // find last user input message and resend
  549. const userIndex = findLastUesrIndex(botMessageId);
  550. if (userIndex === null) return;
  551. setIsLoading(true);
  552. const content = session.messages[userIndex].content;
  553. deleteMessage(userIndex);
  554. chatStore.onUserInput(content).then(() => setIsLoading(false));
  555. inputRef.current?.focus();
  556. };
  557. const context: RenderMessage[] = session.context.slice();
  558. const accessStore = useAccessStore();
  559. if (
  560. context.length === 0 &&
  561. session.messages.at(0)?.content !== BOT_HELLO.content
  562. ) {
  563. const copiedHello = Object.assign({}, BOT_HELLO);
  564. if (!accessStore.isAuthorized()) {
  565. copiedHello.content = Locale.Error.Unauthorized;
  566. }
  567. context.push(copiedHello);
  568. }
  569. // preview messages
  570. const messages = context
  571. .concat(session.messages as RenderMessage[])
  572. .concat(
  573. isLoading
  574. ? [
  575. {
  576. ...createMessage({
  577. role: "assistant",
  578. content: "……",
  579. }),
  580. preview: true,
  581. },
  582. ]
  583. : [],
  584. )
  585. .concat(
  586. userInput.length > 0 && config.sendPreviewBubble
  587. ? [
  588. {
  589. ...createMessage({
  590. role: "user",
  591. content: userInput,
  592. }),
  593. preview: true,
  594. },
  595. ]
  596. : [],
  597. );
  598. const [showPromptModal, setShowPromptModal] = useState(false);
  599. const renameSession = () => {
  600. const newTopic = prompt(Locale.Chat.Rename, session.topic);
  601. if (newTopic && newTopic !== session.topic) {
  602. chatStore.updateCurrentSession((session) => (session.topic = newTopic!));
  603. }
  604. };
  605. // Auto focus
  606. useEffect(() => {
  607. if (isMobileScreen) return;
  608. inputRef.current?.focus();
  609. // eslint-disable-next-line react-hooks/exhaustive-deps
  610. }, []);
  611. return (
  612. <div className={styles.chat} key={session.id}>
  613. <div className={styles["window-header"]}>
  614. <div className={styles["window-header-title"]}>
  615. <div
  616. className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
  617. onClickCapture={renameSession}
  618. >
  619. {!session.topic ? DEFAULT_TOPIC : session.topic}
  620. </div>
  621. <div className={styles["window-header-sub-title"]}>
  622. {Locale.Chat.SubTitle(session.messages.length)}
  623. </div>
  624. </div>
  625. <div className={styles["window-actions"]}>
  626. <div className={styles["window-action-button"] + " " + styles.mobile}>
  627. <IconButton
  628. icon={<ReturnIcon />}
  629. bordered
  630. title={Locale.Chat.Actions.ChatList}
  631. onClick={() => navigate(Path.Home)}
  632. />
  633. </div>
  634. <div className={styles["window-action-button"]}>
  635. <IconButton
  636. icon={<RenameIcon />}
  637. bordered
  638. onClick={renameSession}
  639. />
  640. </div>
  641. <div className={styles["window-action-button"]}>
  642. <IconButton
  643. icon={<ExportIcon />}
  644. bordered
  645. title={Locale.Chat.Actions.Export}
  646. onClick={() => {
  647. exportMessages(
  648. session.messages.filter((msg) => !msg.isError),
  649. session.topic,
  650. );
  651. }}
  652. />
  653. </div>
  654. {!isMobileScreen && (
  655. <div className={styles["window-action-button"]}>
  656. <IconButton
  657. icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
  658. bordered
  659. onClick={() => {
  660. config.update(
  661. (config) => (config.tightBorder = !config.tightBorder),
  662. );
  663. }}
  664. />
  665. </div>
  666. )}
  667. </div>
  668. <PromptToast
  669. showToast={!hitBottom}
  670. showModal={showPromptModal}
  671. setShowModal={setShowPromptModal}
  672. />
  673. </div>
  674. <div
  675. className={styles["chat-body"]}
  676. ref={scrollRef}
  677. onScroll={(e) => onChatBodyScroll(e.currentTarget)}
  678. onMouseDown={() => inputRef.current?.blur()}
  679. onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
  680. onTouchStart={() => {
  681. inputRef.current?.blur();
  682. setAutoScroll(false);
  683. }}
  684. >
  685. {messages.map((message, i) => {
  686. const isUser = message.role === "user";
  687. const showActions =
  688. !isUser &&
  689. i > 0 &&
  690. !(message.preview || message.content.length === 0);
  691. const showTyping = message.preview || message.streaming;
  692. return (
  693. <div
  694. key={i}
  695. className={
  696. isUser ? styles["chat-message-user"] : styles["chat-message"]
  697. }
  698. >
  699. <div className={styles["chat-message-container"]}>
  700. <div className={styles["chat-message-avatar"]}>
  701. {message.role === "user" ? (
  702. <Avatar avatar={config.avatar} />
  703. ) : session.avatar ? (
  704. <Avatar avatar={session.avatar} />
  705. ) : (
  706. <Avatar model={message.model ?? "gpt-3.5-turbo"} />
  707. )}
  708. </div>
  709. {showTyping && (
  710. <div className={styles["chat-message-status"]}>
  711. {Locale.Chat.Typing}
  712. </div>
  713. )}
  714. <div className={styles["chat-message-item"]}>
  715. {showActions && (
  716. <div className={styles["chat-message-top-actions"]}>
  717. {message.streaming ? (
  718. <div
  719. className={styles["chat-message-top-action"]}
  720. onClick={() => onUserStop(message.id ?? i)}
  721. >
  722. {Locale.Chat.Actions.Stop}
  723. </div>
  724. ) : (
  725. <>
  726. <div
  727. className={styles["chat-message-top-action"]}
  728. onClick={() => onDelete(message.id ?? i)}
  729. >
  730. {Locale.Chat.Actions.Delete}
  731. </div>
  732. <div
  733. className={styles["chat-message-top-action"]}
  734. onClick={() => onResend(message.id ?? i)}
  735. >
  736. {Locale.Chat.Actions.Retry}
  737. </div>
  738. </>
  739. )}
  740. <div
  741. className={styles["chat-message-top-action"]}
  742. onClick={() => copyToClipboard(message.content)}
  743. >
  744. {Locale.Chat.Actions.Copy}
  745. </div>
  746. </div>
  747. )}
  748. <Markdown
  749. content={message.content}
  750. loading={
  751. (message.preview || message.content.length === 0) &&
  752. !isUser
  753. }
  754. onContextMenu={(e) => onRightClick(e, message)}
  755. onDoubleClickCapture={() => {
  756. if (!isMobileScreen) return;
  757. setUserInput(message.content);
  758. }}
  759. fontSize={fontSize}
  760. parentRef={scrollRef}
  761. />
  762. </div>
  763. {!isUser && !message.preview && (
  764. <div className={styles["chat-message-actions"]}>
  765. <div className={styles["chat-message-action-date"]}>
  766. {message.date.toLocaleString()}
  767. </div>
  768. </div>
  769. )}
  770. </div>
  771. </div>
  772. );
  773. })}
  774. </div>
  775. <div className={styles["chat-input-panel"]}>
  776. <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
  777. <ChatActions
  778. showPromptModal={() => setShowPromptModal(true)}
  779. scrollToBottom={scrollToBottom}
  780. hitBottom={hitBottom}
  781. />
  782. <div className={styles["chat-input-panel-inner"]}>
  783. <textarea
  784. ref={inputRef}
  785. className={styles["chat-input"]}
  786. placeholder={Locale.Chat.Input(submitKey)}
  787. onInput={(e) => onInput(e.currentTarget.value)}
  788. value={userInput}
  789. onKeyDown={onInputKeyDown}
  790. onFocus={() => setAutoScroll(true)}
  791. onBlur={() => {
  792. setAutoScroll(false);
  793. setTimeout(() => setPromptHints([]), 500);
  794. }}
  795. autoFocus
  796. rows={inputRows}
  797. />
  798. <IconButton
  799. icon={<SendWhiteIcon />}
  800. text={Locale.Chat.Send}
  801. className={styles["chat-input-send"]}
  802. noDark
  803. onClick={onUserSubmit}
  804. />
  805. </div>
  806. </div>
  807. </div>
  808. );
  809. }