exporter.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  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, useMobileScreen } 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. showCopy?: boolean;
  209. }) {
  210. return (
  211. <div className={styles["preview-actions"]}>
  212. {props.showCopy && (
  213. <IconButton
  214. text={Locale.Export.Copy}
  215. bordered
  216. shadow
  217. icon={<CopyIcon />}
  218. onClick={props.copy}
  219. ></IconButton>
  220. )}
  221. <IconButton
  222. text={Locale.Export.Download}
  223. bordered
  224. shadow
  225. icon={<DownloadIcon />}
  226. onClick={props.download}
  227. ></IconButton>
  228. <IconButton
  229. text={Locale.Export.Share}
  230. bordered
  231. shadow
  232. icon={<ShareIcon />}
  233. onClick={() => showToast(Locale.WIP)}
  234. ></IconButton>
  235. </div>
  236. );
  237. }
  238. export function ImagePreviewer(props: {
  239. messages: ChatMessage[];
  240. topic: string;
  241. }) {
  242. const chatStore = useChatStore();
  243. const session = chatStore.currentSession();
  244. const mask = session.mask;
  245. const config = useAppConfig();
  246. const previewRef = useRef<HTMLDivElement>(null);
  247. const copy = () => {
  248. const dom = previewRef.current;
  249. if (!dom) return;
  250. toBlob(dom).then((blob) => {
  251. if (!blob) return;
  252. try {
  253. navigator.clipboard
  254. .write([
  255. new ClipboardItem({
  256. "image/png": blob,
  257. }),
  258. ])
  259. .then(() => {
  260. showToast(Locale.Copy.Success);
  261. });
  262. } catch (e) {
  263. console.error("[Copy Image] ", e);
  264. showToast(Locale.Copy.Failed);
  265. }
  266. });
  267. };
  268. const isMobile = useMobileScreen();
  269. const download = () => {
  270. const dom = previewRef.current;
  271. if (!dom) return;
  272. toPng(dom)
  273. .then((blob) => {
  274. if (!blob) return;
  275. if (isMobile) {
  276. const image = new Image();
  277. image.src = blob;
  278. const win = window.open("");
  279. win?.document.write(image.outerHTML);
  280. } else {
  281. const link = document.createElement("a");
  282. link.download = `${props.topic}.png`;
  283. link.href = blob;
  284. link.click();
  285. }
  286. })
  287. .catch((e) => console.log("[Export Image] ", e));
  288. };
  289. return (
  290. <div className={styles["image-previewer"]}>
  291. <PreviewActions copy={copy} download={download} showCopy={!isMobile} />
  292. <div
  293. className={`${styles["preview-body"]} ${styles["default-theme"]}`}
  294. ref={previewRef}
  295. >
  296. <div className={styles["chat-info"]}>
  297. <div className={styles["logo"] + " no-dark"}>
  298. <ChatGptIcon />
  299. </div>
  300. <div>
  301. <div className={styles["main-title"]}>ChatGPT Next Web</div>
  302. <div className={styles["sub-title"]}>
  303. github.com/Yidadaa/ChatGPT-Next-Web
  304. </div>
  305. <div className={styles["icons"]}>
  306. <Avatar avatar={config.avatar}></Avatar>
  307. <span className={styles["icon-space"]}>&</span>
  308. <MaskAvatar mask={session.mask} />
  309. </div>
  310. </div>
  311. <div>
  312. <div className={styles["chat-info-item"]}>
  313. Model: {mask.modelConfig.model}
  314. </div>
  315. <div className={styles["chat-info-item"]}>
  316. Messages: {props.messages.length}
  317. </div>
  318. <div className={styles["chat-info-item"]}>
  319. Topic: {session.topic}
  320. </div>
  321. <div className={styles["chat-info-item"]}>
  322. Time:{" "}
  323. {new Date(
  324. props.messages.at(-1)?.date ?? Date.now(),
  325. ).toLocaleString()}
  326. </div>
  327. </div>
  328. </div>
  329. {props.messages.map((m, i) => {
  330. return (
  331. <div
  332. className={styles["message"] + " " + styles["message-" + m.role]}
  333. key={i}
  334. >
  335. <div className={styles["avatar"]}>
  336. {m.role === "user" ? (
  337. <Avatar avatar={config.avatar}></Avatar>
  338. ) : (
  339. <MaskAvatar mask={session.mask} />
  340. )}
  341. </div>
  342. <div className={`${styles["body"]} `}>
  343. <Markdown
  344. content={m.content}
  345. fontSize={config.fontSize}
  346. defaultShow
  347. />
  348. </div>
  349. </div>
  350. );
  351. })}
  352. </div>
  353. </div>
  354. );
  355. }
  356. export function MarkdownPreviewer(props: {
  357. messages: ChatMessage[];
  358. topic: string;
  359. }) {
  360. const mdText =
  361. `# ${props.topic}\n\n` +
  362. props.messages
  363. .map((m) => {
  364. return m.role === "user"
  365. ? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
  366. : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
  367. })
  368. .join("\n\n");
  369. const copy = () => {
  370. copyToClipboard(mdText);
  371. };
  372. const download = () => {
  373. downloadAs(mdText, `${props.topic}.md`);
  374. };
  375. return (
  376. <>
  377. <PreviewActions copy={copy} download={download} />
  378. <div className="markdown-body">
  379. <pre className={styles["export-content"]}>{mdText}</pre>
  380. </div>
  381. </>
  382. );
  383. }