home.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. "use client";
  2. import { useState, useRef, useEffect } from "react";
  3. import { Emoji } from "emoji-picker-react";
  4. import { IconButton } from "./button";
  5. import styles from "./home.module.scss";
  6. import { Markdown } from './markdown'
  7. import SettingsIcon from "../icons/settings.svg";
  8. import GithubIcon from "../icons/github.svg";
  9. import ChatGptIcon from "../icons/chatgpt.svg";
  10. import SendWhiteIcon from "../icons/send-white.svg";
  11. import BrainIcon from "../icons/brain.svg";
  12. import ExportIcon from "../icons/export.svg";
  13. import BotIcon from "../icons/bot.svg";
  14. import AddIcon from "../icons/add.svg";
  15. import DeleteIcon from "../icons/delete.svg";
  16. import LoadingIcon from "../icons/three-dots.svg";
  17. import MenuIcon from "../icons/menu.svg";
  18. import CloseIcon from "../icons/close.svg";
  19. import CopyIcon from "../icons/copy.svg";
  20. import DownloadIcon from "../icons/download.svg";
  21. import { Message, SubmitKey, useChatStore, ChatSession } from "../store";
  22. import { Settings } from "./settings";
  23. import { showModal } from "./ui-lib";
  24. import { copyToClipboard, downloadAs, isIOS } from "../utils";
  25. export function Avatar(props: { role: Message["role"] }) {
  26. const config = useChatStore((state) => state.config);
  27. if (props.role === "assistant") {
  28. return <BotIcon className={styles["user-avtar"]} />;
  29. }
  30. return (
  31. <div className={styles["user-avtar"]}>
  32. <Emoji unified={config.avatar} size={18} />
  33. </div>
  34. );
  35. }
  36. export function ChatItem(props: {
  37. onClick?: () => void;
  38. onDelete?: () => void;
  39. title: string;
  40. count: number;
  41. time: string;
  42. selected: boolean;
  43. }) {
  44. return (
  45. <div
  46. className={`${styles["chat-item"]} ${props.selected && styles["chat-item-selected"]
  47. }`}
  48. onClick={props.onClick}
  49. >
  50. <div className={styles["chat-item-title"]}>{props.title}</div>
  51. <div className={styles["chat-item-info"]}>
  52. <div className={styles["chat-item-count"]}>{props.count} 条对话</div>
  53. <div className={styles["chat-item-date"]}>{props.time}</div>
  54. </div>
  55. <div className={styles["chat-item-delete"]} onClick={props.onDelete}>
  56. <DeleteIcon />
  57. </div>
  58. </div>
  59. );
  60. }
  61. export function ChatList() {
  62. const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
  63. (state) => [
  64. state.sessions,
  65. state.currentSessionIndex,
  66. state.selectSession,
  67. state.removeSession,
  68. ]
  69. );
  70. return (
  71. <div className={styles["chat-list"]}>
  72. {sessions.map((item, i) => (
  73. <ChatItem
  74. title={item.topic}
  75. time={item.lastUpdate}
  76. count={item.messages.length}
  77. key={i}
  78. selected={i === selectedIndex}
  79. onClick={() => selectSession(i)}
  80. onDelete={() => removeSession(i)}
  81. />
  82. ))}
  83. </div>
  84. );
  85. }
  86. function useSubmitHandler() {
  87. const config = useChatStore((state) => state.config);
  88. const submitKey = config.submitKey;
  89. const shouldSubmit = (e: KeyboardEvent) => {
  90. if (e.key !== "Enter") return false;
  91. return (
  92. (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
  93. (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
  94. (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
  95. config.submitKey === SubmitKey.Enter
  96. );
  97. };
  98. return {
  99. submitKey,
  100. shouldSubmit,
  101. };
  102. }
  103. export function Chat(props: { showSideBar?: () => void }) {
  104. type RenderMessage = Message & { preview?: boolean };
  105. const session = useChatStore((state) => state.currentSession());
  106. const [userInput, setUserInput] = useState("");
  107. const [isLoading, setIsLoading] = useState(false);
  108. const { submitKey, shouldSubmit } = useSubmitHandler();
  109. const onUserInput = useChatStore((state) => state.onUserInput);
  110. const onUserSubmit = () => {
  111. if (userInput.length <= 0) return;
  112. setIsLoading(true);
  113. onUserInput(userInput).then(() => setIsLoading(false));
  114. setUserInput("");
  115. };
  116. const onInputKeyDown = (e: KeyboardEvent) => {
  117. if (shouldSubmit(e)) {
  118. onUserSubmit();
  119. e.preventDefault();
  120. }
  121. };
  122. const latestMessageRef = useRef<HTMLDivElement>(null);
  123. const messages = (session.messages as RenderMessage[])
  124. .concat(
  125. isLoading
  126. ? [
  127. {
  128. role: "assistant",
  129. content: "……",
  130. date: new Date().toLocaleString(),
  131. preview: true,
  132. },
  133. ]
  134. : []
  135. )
  136. .concat(
  137. userInput.length > 0
  138. ? [
  139. {
  140. role: "user",
  141. content: userInput,
  142. date: new Date().toLocaleString(),
  143. preview: true,
  144. },
  145. ]
  146. : []
  147. );
  148. useEffect(() => {
  149. const dom = latestMessageRef.current
  150. if (dom && !isIOS()) {
  151. dom.scrollIntoView({
  152. behavior: "smooth",
  153. block: "end"
  154. });
  155. }
  156. });
  157. return (
  158. <div className={styles.chat} key={session.id}>
  159. <div className={styles["window-header"]}>
  160. <div className={styles["window-header-title"]}>
  161. <div className={styles["window-header-main-title"]}>{session.topic}</div>
  162. <div className={styles["window-header-sub-title"]}>
  163. 与 ChatGPT 的 {session.messages.length} 条对话
  164. </div>
  165. </div>
  166. <div className={styles["window-actions"]}>
  167. <div className={styles["window-action-button"] + " " + styles.mobile}>
  168. <IconButton
  169. icon={<MenuIcon />}
  170. bordered
  171. title="查看消息列表"
  172. onClick={props?.showSideBar}
  173. />
  174. </div>
  175. <div className={styles["window-action-button"]}>
  176. <IconButton
  177. icon={<BrainIcon />}
  178. bordered
  179. title="查看压缩后的历史 Prompt"
  180. onClick={() => {
  181. showMemoryPrompt(session)
  182. }}
  183. />
  184. </div>
  185. <div className={styles["window-action-button"]}>
  186. <IconButton
  187. icon={<ExportIcon />}
  188. bordered
  189. title="导出聊天记录"
  190. onClick={() => {
  191. exportMessages(session.messages, session.topic)
  192. }}
  193. />
  194. </div>
  195. </div>
  196. </div>
  197. <div className={styles["chat-body"]}>
  198. {messages.map((message, i) => {
  199. const isUser = message.role === "user";
  200. return (
  201. <div
  202. key={i}
  203. className={
  204. isUser ? styles["chat-message-user"] : styles["chat-message"]
  205. }
  206. >
  207. <div className={styles["chat-message-container"]}>
  208. <div className={styles["chat-message-avatar"]}>
  209. <Avatar role={message.role} />
  210. </div>
  211. {(message.preview || message.streaming) && (
  212. <div className={styles["chat-message-status"]}>正在输入…</div>
  213. )}
  214. <div className={styles["chat-message-item"]}>
  215. {(message.preview || message.content.length === 0) &&
  216. !isUser ? (
  217. <LoadingIcon />
  218. ) : (
  219. <div className="markdown-body">
  220. <Markdown content={message.content} />
  221. </div>
  222. )}
  223. </div>
  224. {!isUser && !message.preview && (
  225. <div className={styles["chat-message-actions"]}>
  226. <div className={styles["chat-message-action-date"]}>
  227. {message.date.toLocaleString()}
  228. </div>
  229. </div>
  230. )}
  231. </div>
  232. </div>
  233. );
  234. })}
  235. <span ref={latestMessageRef} style={{ opacity: 0 }}>
  236. -
  237. </span>
  238. </div>
  239. <div className={styles["chat-input-panel"]}>
  240. <div className={styles["chat-input-panel-inner"]}>
  241. <textarea
  242. className={styles["chat-input"]}
  243. placeholder={`输入消息,${submitKey} 发送`}
  244. rows={3}
  245. onInput={(e) => setUserInput(e.currentTarget.value)}
  246. value={userInput}
  247. onKeyDown={(e) => onInputKeyDown(e as any)}
  248. />
  249. <IconButton
  250. icon={<SendWhiteIcon />}
  251. text={"发送"}
  252. className={styles["chat-input-send"] + " no-dark"}
  253. onClick={onUserSubmit}
  254. />
  255. </div>
  256. </div>
  257. </div>
  258. );
  259. }
  260. function useSwitchTheme() {
  261. const config = useChatStore((state) => state.config);
  262. useEffect(() => {
  263. document.body.classList.remove("light");
  264. document.body.classList.remove("dark");
  265. if (config.theme === "dark") {
  266. document.body.classList.add("dark");
  267. } else if (config.theme === "light") {
  268. document.body.classList.add("light");
  269. }
  270. }, [config.theme]);
  271. }
  272. function exportMessages(messages: Message[], topic: string) {
  273. const mdText = `# ${topic}\n\n` + messages.map(m => {
  274. return m.role === 'user' ? `## ${m.content}` : m.content.trim()
  275. }).join('\n\n')
  276. const filename = `${topic}.md`
  277. showModal({
  278. title: "导出聊天记录为 Markdown", children: <div className="markdown-body">
  279. <pre className={styles['export-content']}>{mdText}</pre>
  280. </div>, actions: [
  281. <IconButton key="copy" icon={<CopyIcon />} bordered text="全部复制" onClick={() => copyToClipboard(mdText)} />,
  282. <IconButton key="download" icon={<DownloadIcon />} bordered text="下载文件" onClick={() => downloadAs(mdText, filename)} />
  283. ]
  284. })
  285. }
  286. function showMemoryPrompt(session: ChatSession) {
  287. showModal({
  288. title: `上下文记忆 Prompt (${session.lastSummarizeIndex} of ${session.messages.length})`, children: <div className="markdown-body">
  289. <pre className={styles['export-content']}>{session.memoryPrompt || '无'}</pre>
  290. </div>, actions: [
  291. <IconButton key="copy" icon={<CopyIcon />} bordered text="全部复制" onClick={() => copyToClipboard(session.memoryPrompt)} />,
  292. ]
  293. })
  294. }
  295. export function Home() {
  296. const [createNewSession] = useChatStore((state) => [state.newSession]);
  297. const loading = !useChatStore?.persist?.hasHydrated();
  298. const [showSideBar, setShowSideBar] = useState(true);
  299. // setting
  300. const [openSettings, setOpenSettings] = useState(false);
  301. const config = useChatStore((state) => state.config);
  302. useSwitchTheme();
  303. if (loading) {
  304. return (
  305. <div>
  306. <Avatar role="assistant"></Avatar>
  307. <LoadingIcon />
  308. </div>
  309. );
  310. }
  311. return (
  312. <div
  313. className={`${config.tightBorder ? styles["tight-container"] : styles.container
  314. }`}
  315. >
  316. <div
  317. className={styles.sidebar + ` ${showSideBar && styles["sidebar-show"]}`}
  318. onClick={() => setShowSideBar(false)}
  319. >
  320. <div className={styles["sidebar-header"]}>
  321. <div className={styles["sidebar-title"]}>ChatGPT Next</div>
  322. <div className={styles["sidebar-sub-title"]}>
  323. Build your own AI assistant.
  324. </div>
  325. <div className={styles["sidebar-logo"]}>
  326. <ChatGptIcon />
  327. </div>
  328. </div>
  329. <div
  330. className={styles["sidebar-body"]}
  331. onClick={() => setOpenSettings(false)}
  332. >
  333. <ChatList />
  334. </div>
  335. <div className={styles["sidebar-tail"]}>
  336. <div className={styles["sidebar-actions"]}>
  337. <div className={styles["sidebar-action"] + " " + styles.mobile}>
  338. <IconButton
  339. icon={<CloseIcon />}
  340. onClick={() => setShowSideBar(!showSideBar)}
  341. />
  342. </div>
  343. <div className={styles["sidebar-action"]}>
  344. <IconButton
  345. icon={<SettingsIcon />}
  346. onClick={() => setOpenSettings(!openSettings)}
  347. />
  348. </div>
  349. <div className={styles["sidebar-action"]}>
  350. <a href="https://github.com/Yidadaa" target="_blank">
  351. <IconButton icon={<GithubIcon />} />
  352. </a>
  353. </div>
  354. </div>
  355. <div>
  356. <IconButton
  357. icon={<AddIcon />}
  358. text={"新的聊天"}
  359. onClick={createNewSession}
  360. />
  361. </div>
  362. </div>
  363. </div>
  364. <div className={styles["window-content"]}>
  365. {openSettings ? (
  366. <Settings closeSettings={() => setOpenSettings(false)} />
  367. ) : (
  368. <Chat key="chat" showSideBar={() => setShowSideBar(true)} />
  369. )}
  370. </div>
  371. </div>
  372. );
  373. }