home.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727
  1. "use client";
  2. import { useState, useRef, useEffect, useLayoutEffect } from "react";
  3. import { useDebouncedCallback } from "use-debounce";
  4. import { IconButton } from "./button";
  5. import styles from "./home.module.scss";
  6. import SettingsIcon from "../icons/settings.svg";
  7. import GithubIcon from "../icons/github.svg";
  8. import ChatGptIcon from "../icons/chatgpt.svg";
  9. import SendWhiteIcon from "../icons/send-white.svg";
  10. import BrainIcon from "../icons/brain.svg";
  11. import ExportIcon from "../icons/export.svg";
  12. import BotIcon from "../icons/bot.svg";
  13. import AddIcon from "../icons/add.svg";
  14. import DeleteIcon from "../icons/delete.svg";
  15. import LoadingIcon from "../icons/three-dots.svg";
  16. import MenuIcon from "../icons/menu.svg";
  17. import CloseIcon from "../icons/close.svg";
  18. import CopyIcon from "../icons/copy.svg";
  19. import DownloadIcon from "../icons/download.svg";
  20. import { Message, SubmitKey, useChatStore, ChatSession } from "../store";
  21. import { showModal, showToast } from "./ui-lib";
  22. import {
  23. copyToClipboard,
  24. downloadAs,
  25. isIOS,
  26. isMobileScreen,
  27. selectOrCopy,
  28. } from "../utils";
  29. import Locale from "../locales";
  30. import dynamic from "next/dynamic";
  31. import { REPO_URL } from "../constant";
  32. import { ControllerPool } from "../requests";
  33. import { Prompt, usePromptStore } from "../store/prompt";
  34. import calcTextareaHeight from "../calcTextareaHeight";
  35. export function Loading(props: { noLogo?: boolean }) {
  36. return (
  37. <div className={styles["loading-content"]}>
  38. {!props.noLogo && <BotIcon />}
  39. <LoadingIcon />
  40. </div>
  41. );
  42. }
  43. const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
  44. loading: () => <LoadingIcon />,
  45. });
  46. const Settings = dynamic(async () => (await import("./settings")).Settings, {
  47. loading: () => <Loading noLogo />,
  48. });
  49. const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
  50. loading: () => <LoadingIcon />,
  51. });
  52. export function Avatar(props: { role: Message["role"] }) {
  53. const config = useChatStore((state) => state.config);
  54. if (props.role === "assistant") {
  55. return <BotIcon className={styles["user-avtar"]} />;
  56. }
  57. return (
  58. <div className={styles["user-avtar"]}>
  59. <Emoji unified={config.avatar} size={18} />
  60. </div>
  61. );
  62. }
  63. export function ChatItem(props: {
  64. onClick?: () => void;
  65. onDelete?: () => void;
  66. title: string;
  67. count: number;
  68. time: string;
  69. selected: boolean;
  70. }) {
  71. return (
  72. <div
  73. className={`${styles["chat-item"]} ${
  74. props.selected && styles["chat-item-selected"]
  75. }`}
  76. onClick={props.onClick}
  77. >
  78. <div className={styles["chat-item-title"]}>{props.title}</div>
  79. <div className={styles["chat-item-info"]}>
  80. <div className={styles["chat-item-count"]}>
  81. {Locale.ChatItem.ChatItemCount(props.count)}
  82. </div>
  83. <div className={styles["chat-item-date"]}>{props.time}</div>
  84. </div>
  85. <div className={styles["chat-item-delete"]} onClick={props.onDelete}>
  86. <DeleteIcon />
  87. </div>
  88. </div>
  89. );
  90. }
  91. export function ChatList() {
  92. const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
  93. (state) => [
  94. state.sessions,
  95. state.currentSessionIndex,
  96. state.selectSession,
  97. state.removeSession,
  98. ]
  99. );
  100. return (
  101. <div className={styles["chat-list"]}>
  102. {sessions.map((item, i) => (
  103. <ChatItem
  104. title={item.topic}
  105. time={item.lastUpdate}
  106. count={item.messages.length}
  107. key={i}
  108. selected={i === selectedIndex}
  109. onClick={() => selectSession(i)}
  110. onDelete={() => confirm(Locale.Home.DeleteChat) && removeSession(i)}
  111. />
  112. ))}
  113. </div>
  114. );
  115. }
  116. function useSubmitHandler() {
  117. const config = useChatStore((state) => state.config);
  118. const submitKey = config.submitKey;
  119. const shouldSubmit = (e: KeyboardEvent) => {
  120. if (e.key !== "Enter") return false;
  121. return (
  122. (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
  123. (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
  124. (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
  125. (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
  126. (config.submitKey === SubmitKey.Enter &&
  127. !e.altKey &&
  128. !e.ctrlKey &&
  129. !e.shiftKey &&
  130. !e.metaKey)
  131. );
  132. };
  133. return {
  134. submitKey,
  135. shouldSubmit,
  136. };
  137. }
  138. export function PromptHints(props: {
  139. prompts: Prompt[];
  140. onPromptSelect: (prompt: Prompt) => void;
  141. }) {
  142. if (props.prompts.length === 0) return null;
  143. return (
  144. <div className={styles["prompt-hints"]}>
  145. {props.prompts.map((prompt, i) => (
  146. <div
  147. className={styles["prompt-hint"]}
  148. key={prompt.title + i.toString()}
  149. onClick={() => props.onPromptSelect(prompt)}
  150. >
  151. <div className={styles["hint-title"]}>{prompt.title}</div>
  152. <div className={styles["hint-content"]}>{prompt.content}</div>
  153. </div>
  154. ))}
  155. </div>
  156. );
  157. }
  158. export function Chat(props: {
  159. showSideBar?: () => void;
  160. sideBarShowing?: boolean;
  161. autoSize: {
  162. minRows: number;
  163. maxRows: number;
  164. };
  165. }) {
  166. type RenderMessage = Message & { preview?: boolean };
  167. const chatStore = useChatStore();
  168. const [session, sessionIndex] = useChatStore((state) => [
  169. state.currentSession(),
  170. state.currentSessionIndex,
  171. ]);
  172. const fontSize = useChatStore((state) => state.config.fontSize);
  173. const inputRef = useRef<HTMLTextAreaElement>(null);
  174. const [userInput, setUserInput] = useState("");
  175. const [isLoading, setIsLoading] = useState(false);
  176. const [textareaStyle, setTextareaStyle] = useState({});
  177. const { submitKey, shouldSubmit } = useSubmitHandler();
  178. // prompt hints
  179. const promptStore = usePromptStore();
  180. const [promptHints, setPromptHints] = useState<Prompt[]>([]);
  181. const onSearch = useDebouncedCallback(
  182. (text: string) => {
  183. setPromptHints(promptStore.search(text));
  184. },
  185. 100,
  186. { leading: true, trailing: true }
  187. );
  188. const onPromptSelect = (prompt: Prompt) => {
  189. setUserInput(prompt.content);
  190. setPromptHints([]);
  191. inputRef.current?.focus();
  192. };
  193. const scrollInput = () => {
  194. const dom = inputRef.current;
  195. if (!dom) return;
  196. const paddingBottomNum: number = parseInt(
  197. window.getComputedStyle(dom).paddingBottom,
  198. 10
  199. );
  200. dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum;
  201. };
  202. // only search prompts when user input is short
  203. const SEARCH_TEXT_LIMIT = 30;
  204. const onInput = (text: string) => {
  205. scrollInput();
  206. setUserInput(text);
  207. const n = text.trim().length;
  208. // clear search results
  209. if (n === 0) {
  210. setPromptHints([]);
  211. } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
  212. // check if need to trigger auto completion
  213. if (text.startsWith("/") && text.length > 1) {
  214. onSearch(text.slice(1));
  215. }
  216. }
  217. resizeTextarea();
  218. };
  219. // set style for textarea
  220. const resizeTextarea = () => {
  221. const { minRows, maxRows } = props.autoSize;
  222. setTextareaStyle(calcTextareaHeight(inputRef.current, minRows, maxRows));
  223. };
  224. // submit user input
  225. const onUserSubmit = () => {
  226. if (userInput.length <= 0) return;
  227. setIsLoading(true);
  228. chatStore.onUserInput(userInput).then(() => setIsLoading(false));
  229. setUserInput("");
  230. setPromptHints([]);
  231. inputRef.current?.focus();
  232. };
  233. // stop response
  234. const onUserStop = (messageIndex: number) => {
  235. console.log(ControllerPool, sessionIndex, messageIndex);
  236. ControllerPool.stop(sessionIndex, messageIndex);
  237. };
  238. // check if should send message
  239. const onInputKeyDown = (e: KeyboardEvent) => {
  240. if (shouldSubmit(e)) {
  241. onUserSubmit();
  242. e.preventDefault();
  243. }
  244. };
  245. const onRightClick = (e: any, message: Message) => {
  246. // auto fill user input
  247. if (message.role === "user") {
  248. setUserInput(message.content);
  249. }
  250. // copy to clipboard
  251. if (selectOrCopy(e.currentTarget, message.content)) {
  252. e.preventDefault();
  253. }
  254. };
  255. const onResend = (botIndex: number) => {
  256. // find last user input message and resend
  257. for (let i = botIndex; i >= 0; i -= 1) {
  258. if (messages[i].role === "user") {
  259. setIsLoading(true);
  260. chatStore
  261. .onUserInput(messages[i].content)
  262. .then(() => setIsLoading(false));
  263. inputRef.current?.focus();
  264. return;
  265. }
  266. }
  267. };
  268. // for auto-scroll
  269. const latestMessageRef = useRef<HTMLDivElement>(null);
  270. const [autoScroll, setAutoScroll] = useState(true);
  271. // preview messages
  272. const messages = (session.messages as RenderMessage[])
  273. .concat(
  274. isLoading
  275. ? [
  276. {
  277. role: "assistant",
  278. content: "……",
  279. date: new Date().toLocaleString(),
  280. preview: true,
  281. },
  282. ]
  283. : []
  284. )
  285. .concat(
  286. userInput.length > 0
  287. ? [
  288. {
  289. role: "user",
  290. content: userInput,
  291. date: new Date().toLocaleString(),
  292. preview: true,
  293. },
  294. ]
  295. : []
  296. );
  297. // auto scroll
  298. useLayoutEffect(() => {
  299. setTimeout(() => {
  300. const dom = latestMessageRef.current;
  301. const inputDom = inputRef.current;
  302. // only scroll when input overlaped message body
  303. let shouldScroll = true;
  304. if (dom && inputDom) {
  305. const domRect = dom.getBoundingClientRect();
  306. const inputRect = inputDom.getBoundingClientRect();
  307. shouldScroll = domRect.top > inputRect.top;
  308. }
  309. if (dom && autoScroll && shouldScroll) {
  310. dom.scrollIntoView({
  311. block: "end",
  312. });
  313. }
  314. }, 500);
  315. });
  316. return (
  317. <div className={styles.chat} key={session.id}>
  318. <div className={styles["window-header"]}>
  319. <div
  320. className={styles["window-header-title"]}
  321. onClick={props?.showSideBar}
  322. >
  323. <div
  324. className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
  325. onClick={() => {
  326. const newTopic = prompt(Locale.Chat.Rename, session.topic);
  327. if (newTopic && newTopic !== session.topic) {
  328. chatStore.updateCurrentSession(
  329. (session) => (session.topic = newTopic!)
  330. );
  331. }
  332. }}
  333. >
  334. {session.topic}
  335. </div>
  336. <div className={styles["window-header-sub-title"]}>
  337. {Locale.Chat.SubTitle(session.messages.length)}
  338. </div>
  339. </div>
  340. <div className={styles["window-actions"]}>
  341. <div className={styles["window-action-button"] + " " + styles.mobile}>
  342. <IconButton
  343. icon={<MenuIcon />}
  344. bordered
  345. title={Locale.Chat.Actions.ChatList}
  346. onClick={props?.showSideBar}
  347. />
  348. </div>
  349. <div className={styles["window-action-button"]}>
  350. <IconButton
  351. icon={<BrainIcon />}
  352. bordered
  353. title={Locale.Chat.Actions.CompressedHistory}
  354. onClick={() => {
  355. showMemoryPrompt(session);
  356. }}
  357. />
  358. </div>
  359. <div className={styles["window-action-button"]}>
  360. <IconButton
  361. icon={<ExportIcon />}
  362. bordered
  363. title={Locale.Chat.Actions.Export}
  364. onClick={() => {
  365. exportMessages(session.messages, session.topic);
  366. }}
  367. />
  368. </div>
  369. </div>
  370. </div>
  371. <div className={styles["chat-body"]}>
  372. {messages.map((message, i) => {
  373. const isUser = message.role === "user";
  374. return (
  375. <div
  376. key={i}
  377. className={
  378. isUser ? styles["chat-message-user"] : styles["chat-message"]
  379. }
  380. >
  381. <div className={styles["chat-message-container"]}>
  382. <div className={styles["chat-message-avatar"]}>
  383. <Avatar role={message.role} />
  384. </div>
  385. {(message.preview || message.streaming) && (
  386. <div className={styles["chat-message-status"]}>
  387. {Locale.Chat.Typing}
  388. </div>
  389. )}
  390. <div className={styles["chat-message-item"]}>
  391. {!isUser &&
  392. !(message.preview || message.content.length === 0) && (
  393. <div className={styles["chat-message-top-actions"]}>
  394. {message.streaming ? (
  395. <div
  396. className={styles["chat-message-top-action"]}
  397. onClick={() => onUserStop(i)}
  398. >
  399. {Locale.Chat.Actions.Stop}
  400. </div>
  401. ) : (
  402. <div
  403. className={styles["chat-message-top-action"]}
  404. onClick={() => onResend(i)}
  405. >
  406. {Locale.Chat.Actions.Retry}
  407. </div>
  408. )}
  409. <div
  410. className={styles["chat-message-top-action"]}
  411. onClick={() => copyToClipboard(message.content)}
  412. >
  413. {Locale.Chat.Actions.Copy}
  414. </div>
  415. </div>
  416. )}
  417. {(message.preview || message.content.length === 0) &&
  418. !isUser ? (
  419. <LoadingIcon />
  420. ) : (
  421. <div
  422. className="markdown-body"
  423. style={{ fontSize: `${fontSize}px` }}
  424. onContextMenu={(e) => onRightClick(e, message)}
  425. onDoubleClickCapture={() => {
  426. if (!isMobileScreen()) return;
  427. setUserInput(message.content);
  428. }}
  429. >
  430. <Markdown content={message.content} />
  431. </div>
  432. )}
  433. </div>
  434. {!isUser && !message.preview && (
  435. <div className={styles["chat-message-actions"]}>
  436. <div className={styles["chat-message-action-date"]}>
  437. {message.date.toLocaleString()}
  438. </div>
  439. </div>
  440. )}
  441. </div>
  442. </div>
  443. );
  444. })}
  445. <div ref={latestMessageRef} style={{ opacity: 0, height: "1px" }}>
  446. -
  447. </div>
  448. </div>
  449. <div className={styles["chat-input-panel"]}>
  450. <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
  451. <div className={styles["chat-input-panel-inner"]}>
  452. <textarea
  453. ref={inputRef}
  454. style={textareaStyle}
  455. className={styles["chat-input"]}
  456. placeholder={Locale.Chat.Input(submitKey)}
  457. onInput={(e) => onInput(e.currentTarget.value)}
  458. value={userInput}
  459. onKeyDown={(e) => onInputKeyDown(e as any)}
  460. onFocus={() => setAutoScroll(true)}
  461. onBlur={() => {
  462. setAutoScroll(false);
  463. setTimeout(() => setPromptHints([]), 500);
  464. }}
  465. autoFocus={!props?.sideBarShowing}
  466. />
  467. <IconButton
  468. icon={<SendWhiteIcon />}
  469. text={Locale.Chat.Send}
  470. className={styles["chat-input-send"] + " no-dark"}
  471. onClick={onUserSubmit}
  472. />
  473. </div>
  474. </div>
  475. </div>
  476. );
  477. }
  478. function useSwitchTheme() {
  479. const config = useChatStore((state) => state.config);
  480. useEffect(() => {
  481. document.body.classList.remove("light");
  482. document.body.classList.remove("dark");
  483. if (config.theme === "dark") {
  484. document.body.classList.add("dark");
  485. } else if (config.theme === "light") {
  486. document.body.classList.add("light");
  487. }
  488. const themeColor = getComputedStyle(document.body)
  489. .getPropertyValue("--theme-color")
  490. .trim();
  491. const metaDescription = document.querySelector('meta[name="theme-color"]');
  492. metaDescription?.setAttribute("content", themeColor);
  493. }, [config.theme]);
  494. }
  495. function exportMessages(messages: Message[], topic: string) {
  496. const mdText =
  497. `# ${topic}\n\n` +
  498. messages
  499. .map((m) => {
  500. return m.role === "user" ? `## ${m.content}` : m.content.trim();
  501. })
  502. .join("\n\n");
  503. const filename = `${topic}.md`;
  504. showModal({
  505. title: Locale.Export.Title,
  506. children: (
  507. <div className="markdown-body">
  508. <pre className={styles["export-content"]}>{mdText}</pre>
  509. </div>
  510. ),
  511. actions: [
  512. <IconButton
  513. key="copy"
  514. icon={<CopyIcon />}
  515. bordered
  516. text={Locale.Export.Copy}
  517. onClick={() => copyToClipboard(mdText)}
  518. />,
  519. <IconButton
  520. key="download"
  521. icon={<DownloadIcon />}
  522. bordered
  523. text={Locale.Export.Download}
  524. onClick={() => downloadAs(mdText, filename)}
  525. />,
  526. ],
  527. });
  528. }
  529. function showMemoryPrompt(session: ChatSession) {
  530. showModal({
  531. title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`,
  532. children: (
  533. <div className="markdown-body">
  534. <pre className={styles["export-content"]}>
  535. {session.memoryPrompt || Locale.Memory.EmptyContent}
  536. </pre>
  537. </div>
  538. ),
  539. actions: [
  540. <IconButton
  541. key="copy"
  542. icon={<CopyIcon />}
  543. bordered
  544. text={Locale.Memory.Copy}
  545. onClick={() => copyToClipboard(session.memoryPrompt)}
  546. />,
  547. ],
  548. });
  549. }
  550. const useHasHydrated = () => {
  551. const [hasHydrated, setHasHydrated] = useState<boolean>(false);
  552. useEffect(() => {
  553. setHasHydrated(true);
  554. }, []);
  555. return hasHydrated;
  556. };
  557. export function Home() {
  558. const [createNewSession, currentIndex, removeSession] = useChatStore(
  559. (state) => [
  560. state.newSession,
  561. state.currentSessionIndex,
  562. state.removeSession,
  563. ]
  564. );
  565. const loading = !useHasHydrated();
  566. const [showSideBar, setShowSideBar] = useState(true);
  567. // setting
  568. const [openSettings, setOpenSettings] = useState(false);
  569. const config = useChatStore((state) => state.config);
  570. useSwitchTheme();
  571. if (loading) {
  572. return <Loading />;
  573. }
  574. return (
  575. <div
  576. className={`${
  577. config.tightBorder && !isMobileScreen()
  578. ? styles["tight-container"]
  579. : styles.container
  580. }`}
  581. >
  582. <div
  583. className={styles.sidebar + ` ${showSideBar && styles["sidebar-show"]}`}
  584. >
  585. <div className={styles["sidebar-header"]}>
  586. <div className={styles["sidebar-title"]}>ChatGPT Next</div>
  587. <div className={styles["sidebar-sub-title"]}>
  588. Build your own AI assistant.
  589. </div>
  590. <div className={styles["sidebar-logo"]}>
  591. <ChatGptIcon />
  592. </div>
  593. </div>
  594. <div
  595. className={styles["sidebar-body"]}
  596. onClick={() => {
  597. setOpenSettings(false);
  598. setShowSideBar(false);
  599. }}
  600. >
  601. <ChatList />
  602. </div>
  603. <div className={styles["sidebar-tail"]}>
  604. <div className={styles["sidebar-actions"]}>
  605. <div className={styles["sidebar-action"] + " " + styles.mobile}>
  606. <IconButton
  607. icon={<CloseIcon />}
  608. onClick={() => {
  609. if (confirm(Locale.Home.DeleteChat)) {
  610. removeSession(currentIndex);
  611. }
  612. }}
  613. />
  614. </div>
  615. <div className={styles["sidebar-action"]}>
  616. <IconButton
  617. icon={<SettingsIcon />}
  618. onClick={() => {
  619. setOpenSettings(true);
  620. setShowSideBar(false);
  621. }}
  622. />
  623. </div>
  624. <div className={styles["sidebar-action"]}>
  625. <a href={REPO_URL} target="_blank">
  626. <IconButton icon={<GithubIcon />} />
  627. </a>
  628. </div>
  629. </div>
  630. <div>
  631. <IconButton
  632. icon={<AddIcon />}
  633. text={Locale.Home.NewChat}
  634. onClick={() => {
  635. createNewSession();
  636. setShowSideBar(false);
  637. }}
  638. />
  639. </div>
  640. </div>
  641. </div>
  642. <div className={styles["window-content"]}>
  643. {openSettings ? (
  644. <Settings
  645. closeSettings={() => {
  646. setOpenSettings(false);
  647. setShowSideBar(true);
  648. }}
  649. />
  650. ) : (
  651. <Chat
  652. key="chat"
  653. showSideBar={() => setShowSideBar(true)}
  654. sideBarShowing={showSideBar}
  655. autoSize={{ minRows: 2, maxRows: 6 }}
  656. />
  657. )}
  658. </div>
  659. </div>
  660. );
  661. }