exporter.tsx 11 KB

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