exporter.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import { ChatMessage, useAppConfig, useChatStore } from "../store";
  2. import Locale from "../locales";
  3. import styles from "./exporter.module.scss";
  4. import { List, ListItem, Modal, Select, showToast } from "./ui-lib";
  5. import { IconButton } from "./button";
  6. import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
  7. import CopyIcon from "../icons/copy.svg";
  8. import LoadingIcon from "../icons/three-dots.svg";
  9. import ChatGptIcon from "../icons/chatgpt.png";
  10. import ShareIcon from "../icons/share.svg";
  11. import BotIcon from "../icons/bot.png";
  12. import DownloadIcon from "../icons/download.svg";
  13. import { useMemo, useRef, useState } from "react";
  14. import { MessageSelector, useMessageSelector } from "./message-selector";
  15. import { Avatar } from "./emoji";
  16. import dynamic from "next/dynamic";
  17. import NextImage from "next/image";
  18. import { toBlob, toPng } from "html-to-image";
  19. import { DEFAULT_MASK_AVATAR } from "../store/mask";
  20. const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
  21. loading: () => <LoadingIcon />,
  22. });
  23. export function ExportMessageModal(props: { onClose: () => void }) {
  24. return (
  25. <div className="modal-mask">
  26. <Modal title={Locale.Export.Title} onClose={props.onClose}>
  27. <div style={{ minHeight: "40vh" }}>
  28. <MessageExporter />
  29. </div>
  30. </Modal>
  31. </div>
  32. );
  33. }
  34. function useSteps(
  35. steps: Array<{
  36. name: string;
  37. value: string;
  38. }>,
  39. ) {
  40. const stepCount = steps.length;
  41. const [currentStepIndex, setCurrentStepIndex] = useState(0);
  42. const nextStep = () =>
  43. setCurrentStepIndex((currentStepIndex + 1) % stepCount);
  44. const prevStep = () =>
  45. setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
  46. return {
  47. currentStepIndex,
  48. setCurrentStepIndex,
  49. nextStep,
  50. prevStep,
  51. currentStep: steps[currentStepIndex],
  52. };
  53. }
  54. function Steps<
  55. T extends {
  56. name: string;
  57. value: string;
  58. }[],
  59. >(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
  60. const steps = props.steps;
  61. const stepCount = steps.length;
  62. return (
  63. <div className={styles["steps"]}>
  64. <div className={styles["steps-progress"]}>
  65. <div
  66. className={styles["steps-progress-inner"]}
  67. style={{
  68. width: `${((props.index + 1) / stepCount) * 100}%`,
  69. }}
  70. ></div>
  71. </div>
  72. <div className={styles["steps-inner"]}>
  73. {steps.map((step, i) => {
  74. return (
  75. <div
  76. key={i}
  77. className={`${styles["step"]} ${
  78. styles[i <= props.index ? "step-finished" : ""]
  79. } ${i === props.index && styles["step-current"]} clickable`}
  80. onClick={() => {
  81. props.onStepChange?.(i);
  82. }}
  83. role="button"
  84. >
  85. <span className={styles["step-index"]}>{i + 1}</span>
  86. <span className={styles["step-name"]}>{step.name}</span>
  87. </div>
  88. );
  89. })}
  90. </div>
  91. </div>
  92. );
  93. }
  94. export function MessageExporter() {
  95. const steps = [
  96. {
  97. name: Locale.Export.Steps.Select,
  98. value: "select",
  99. },
  100. {
  101. name: Locale.Export.Steps.Preview,
  102. value: "preview",
  103. },
  104. ];
  105. const { currentStep, setCurrentStepIndex, currentStepIndex } =
  106. useSteps(steps);
  107. const formats = ["text", "image"] as const;
  108. type ExportFormat = (typeof formats)[number];
  109. const [exportConfig, setExportConfig] = useState({
  110. format: "image" as ExportFormat,
  111. includeContext: true,
  112. });
  113. function updateExportConfig(updater: (config: typeof exportConfig) => void) {
  114. const config = { ...exportConfig };
  115. updater(config);
  116. setExportConfig(config);
  117. }
  118. const chatStore = useChatStore();
  119. const session = chatStore.currentSession();
  120. const { selection, updateSelection } = useMessageSelector();
  121. const selectedMessages = useMemo(() => {
  122. const ret: ChatMessage[] = [];
  123. if (exportConfig.includeContext) {
  124. ret.push(...session.mask.context);
  125. }
  126. ret.push(...session.messages.filter((m, i) => selection.has(m.id ?? i)));
  127. return ret;
  128. }, [
  129. exportConfig.includeContext,
  130. session.messages,
  131. session.mask.context,
  132. selection,
  133. ]);
  134. return (
  135. <>
  136. <Steps
  137. steps={steps}
  138. index={currentStepIndex}
  139. onStepChange={setCurrentStepIndex}
  140. />
  141. <div className={styles["message-exporter-body"]}>
  142. {currentStep.value === "select" && (
  143. <>
  144. <List>
  145. <ListItem
  146. title={Locale.Export.Format.Title}
  147. subTitle={Locale.Export.Format.SubTitle}
  148. >
  149. <Select
  150. value={exportConfig.format}
  151. onChange={(e) =>
  152. updateExportConfig(
  153. (config) =>
  154. (config.format = e.currentTarget.value as ExportFormat),
  155. )
  156. }
  157. >
  158. {formats.map((f) => (
  159. <option key={f} value={f}>
  160. {f}
  161. </option>
  162. ))}
  163. </Select>
  164. </ListItem>
  165. <ListItem
  166. title={Locale.Export.IncludeContext.Title}
  167. subTitle={Locale.Export.IncludeContext.SubTitle}
  168. >
  169. <input
  170. type="checkbox"
  171. checked={exportConfig.includeContext}
  172. onChange={(e) => {
  173. updateExportConfig(
  174. (config) =>
  175. (config.includeContext = e.currentTarget.checked),
  176. );
  177. }}
  178. ></input>
  179. </ListItem>
  180. </List>
  181. <MessageSelector
  182. selection={selection}
  183. updateSelection={updateSelection}
  184. defaultSelectAll
  185. />
  186. </>
  187. )}
  188. {currentStep.value === "preview" && (
  189. <>
  190. {exportConfig.format === "text" ? (
  191. <MarkdownPreviewer
  192. messages={selectedMessages}
  193. topic={session.topic}
  194. />
  195. ) : (
  196. <ImagePreviewer
  197. messages={selectedMessages}
  198. topic={session.topic}
  199. />
  200. )}
  201. </>
  202. )}
  203. </div>
  204. </>
  205. );
  206. }
  207. export function PreviewActions(props: {
  208. download: () => void;
  209. copy: () => void;
  210. showCopy?: boolean;
  211. }) {
  212. return (
  213. <div className={styles["preview-actions"]}>
  214. {props.showCopy && (
  215. <IconButton
  216. text={Locale.Export.Copy}
  217. bordered
  218. shadow
  219. icon={<CopyIcon />}
  220. onClick={props.copy}
  221. ></IconButton>
  222. )}
  223. <IconButton
  224. text={Locale.Export.Download}
  225. bordered
  226. shadow
  227. icon={<DownloadIcon />}
  228. onClick={props.download}
  229. ></IconButton>
  230. <IconButton
  231. text={Locale.Export.Share}
  232. bordered
  233. shadow
  234. icon={<ShareIcon />}
  235. onClick={() => showToast(Locale.WIP)}
  236. ></IconButton>
  237. </div>
  238. );
  239. }
  240. function ExportAvatar(props: { avatar: string }) {
  241. if (props.avatar === DEFAULT_MASK_AVATAR) {
  242. return (
  243. <NextImage
  244. src={BotIcon.src}
  245. width={30}
  246. height={30}
  247. alt="bot"
  248. className="user-avatar"
  249. />
  250. );
  251. }
  252. return <Avatar avatar={props.avatar}></Avatar>;
  253. }
  254. export function ImagePreviewer(props: {
  255. messages: ChatMessage[];
  256. topic: string;
  257. }) {
  258. const chatStore = useChatStore();
  259. const session = chatStore.currentSession();
  260. const mask = session.mask;
  261. const config = useAppConfig();
  262. const previewRef = useRef<HTMLDivElement>(null);
  263. const copy = () => {
  264. const dom = previewRef.current;
  265. if (!dom) return;
  266. toBlob(dom).then((blob) => {
  267. if (!blob) return;
  268. try {
  269. navigator.clipboard
  270. .write([
  271. new ClipboardItem({
  272. "image/png": blob,
  273. }),
  274. ])
  275. .then(() => {
  276. showToast(Locale.Copy.Success);
  277. });
  278. } catch (e) {
  279. console.error("[Copy Image] ", e);
  280. showToast(Locale.Copy.Failed);
  281. }
  282. });
  283. };
  284. const isMobile = useMobileScreen();
  285. const download = () => {
  286. const dom = previewRef.current;
  287. if (!dom) return;
  288. toPng(dom)
  289. .then((blob) => {
  290. if (!blob) return;
  291. if (isMobile) {
  292. const image = new Image();
  293. image.src = blob;
  294. const win = window.open("");
  295. win?.document.write(image.outerHTML);
  296. } else {
  297. const link = document.createElement("a");
  298. link.download = `${props.topic}.png`;
  299. link.href = blob;
  300. link.click();
  301. }
  302. })
  303. .catch((e) => console.log("[Export Image] ", e));
  304. };
  305. return (
  306. <div className={styles["image-previewer"]}>
  307. <PreviewActions copy={copy} download={download} showCopy={!isMobile} />
  308. <div
  309. className={`${styles["preview-body"]} ${styles["default-theme"]}`}
  310. ref={previewRef}
  311. >
  312. <div className={styles["chat-info"]}>
  313. <div className={styles["logo"] + " no-dark"}>
  314. <NextImage
  315. src={ChatGptIcon.src}
  316. alt="logo"
  317. width={50}
  318. height={50}
  319. />
  320. </div>
  321. <div>
  322. <div className={styles["main-title"]}>ChatGPT Next Web</div>
  323. <div className={styles["sub-title"]}>
  324. github.com/Yidadaa/ChatGPT-Next-Web
  325. </div>
  326. <div className={styles["icons"]}>
  327. <ExportAvatar avatar={config.avatar} />
  328. <span className={styles["icon-space"]}>&</span>
  329. <ExportAvatar avatar={mask.avatar} />
  330. </div>
  331. </div>
  332. <div>
  333. <div className={styles["chat-info-item"]}>
  334. Model: {mask.modelConfig.model}
  335. </div>
  336. <div className={styles["chat-info-item"]}>
  337. Messages: {props.messages.length}
  338. </div>
  339. <div className={styles["chat-info-item"]}>
  340. Topic: {session.topic}
  341. </div>
  342. <div className={styles["chat-info-item"]}>
  343. Time:{" "}
  344. {new Date(
  345. props.messages.at(-1)?.date ?? Date.now(),
  346. ).toLocaleString()}
  347. </div>
  348. </div>
  349. </div>
  350. {props.messages.map((m, i) => {
  351. return (
  352. <div
  353. className={styles["message"] + " " + styles["message-" + m.role]}
  354. key={i}
  355. >
  356. <div className={styles["avatar"]}>
  357. <ExportAvatar
  358. avatar={m.role === "user" ? config.avatar : mask.avatar}
  359. />
  360. </div>
  361. <div className={styles["body"]}>
  362. <Markdown
  363. content={m.content}
  364. fontSize={config.fontSize}
  365. defaultShow
  366. />
  367. </div>
  368. </div>
  369. );
  370. })}
  371. </div>
  372. </div>
  373. );
  374. }
  375. export function MarkdownPreviewer(props: {
  376. messages: ChatMessage[];
  377. topic: string;
  378. }) {
  379. const mdText =
  380. `# ${props.topic}\n\n` +
  381. props.messages
  382. .map((m) => {
  383. return m.role === "user"
  384. ? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
  385. : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
  386. })
  387. .join("\n\n");
  388. const copy = () => {
  389. copyToClipboard(mdText);
  390. };
  391. const download = () => {
  392. downloadAs(mdText, `${props.topic}.md`);
  393. };
  394. return (
  395. <>
  396. <PreviewActions copy={copy} download={download} />
  397. <div className="markdown-body">
  398. <pre className={styles["export-content"]}>{mdText}</pre>
  399. </div>
  400. </>
  401. );
  402. }