home.tsx 17 KB

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