exporter.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. /* eslint-disable @next/next/no-img-element */
  2. import { ChatMessage, useAppConfig, useChatStore } from "../store";
  3. import Locale from "../locales";
  4. import styles from "./exporter.module.scss";
  5. import {
  6. List,
  7. ListItem,
  8. Modal,
  9. Select,
  10. showImageModal,
  11. showModal,
  12. showToast,
  13. } from "./ui-lib";
  14. import { IconButton } from "./button";
  15. import {
  16. copyToClipboard,
  17. downloadAs,
  18. getMessageImages,
  19. useMobileScreen,
  20. } from "../utils";
  21. import CopyIcon from "../icons/copy.svg";
  22. import LoadingIcon from "../icons/three-dots.svg";
  23. import ChatGptIcon from "../icons/chatgpt.png";
  24. import ShareIcon from "../icons/share.svg";
  25. import DownloadIcon from "../icons/download.svg";
  26. import { useEffect, useMemo, useRef, useState } from "react";
  27. import { MessageSelector, useMessageSelector } from "./message-selector";
  28. import { Avatar } from "./avatar";
  29. import dynamic from "next/dynamic";
  30. import NextImage from "next/image";
  31. import { toBlob, toPng } from "html-to-image";
  32. import { prettyObject } from "../utils/format";
  33. import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
  34. import { getClientConfig } from "../config/client";
  35. import { type ClientApi, getClientApi } from "../client/api";
  36. import { getMessageTextContent } from "../utils";
  37. import { MaskAvatar } from "./mask";
  38. import clsx from "clsx";
  39. const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
  40. loading: () => <LoadingIcon />,
  41. });
  42. export function ExportMessageModal(props: { onClose: () => void }) {
  43. return (
  44. <div className="modal-mask">
  45. <Modal
  46. title={Locale.Export.Title}
  47. onClose={props.onClose}
  48. footer={
  49. <div
  50. style={{
  51. width: "100%",
  52. textAlign: "center",
  53. fontSize: 14,
  54. opacity: 0.5,
  55. }}
  56. >
  57. {Locale.Exporter.Description.Title}
  58. </div>
  59. }
  60. >
  61. <div style={{ minHeight: "40vh" }}>
  62. <MessageExporter />
  63. </div>
  64. </Modal>
  65. </div>
  66. );
  67. }
  68. function useSteps(
  69. steps: Array<{
  70. name: string;
  71. value: string;
  72. }>,
  73. ) {
  74. const stepCount = steps.length;
  75. const [currentStepIndex, setCurrentStepIndex] = useState(0);
  76. const nextStep = () =>
  77. setCurrentStepIndex((currentStepIndex + 1) % stepCount);
  78. const prevStep = () =>
  79. setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
  80. return {
  81. currentStepIndex,
  82. setCurrentStepIndex,
  83. nextStep,
  84. prevStep,
  85. currentStep: steps[currentStepIndex],
  86. };
  87. }
  88. function Steps<
  89. T extends {
  90. name: string;
  91. value: string;
  92. }[],
  93. >(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
  94. const steps = props.steps;
  95. const stepCount = steps.length;
  96. return (
  97. <div className={styles["steps"]}>
  98. <div className={styles["steps-progress"]}>
  99. <div
  100. className={styles["steps-progress-inner"]}
  101. style={{
  102. width: `${((props.index + 1) / stepCount) * 100}%`,
  103. }}
  104. ></div>
  105. </div>
  106. <div className={styles["steps-inner"]}>
  107. {steps.map((step, i) => {
  108. return (
  109. <div
  110. key={i}
  111. className={clsx("clickable", styles["step"], {
  112. [styles["step-finished"]]: i <= props.index,
  113. [styles["step-current"]]: i === props.index,
  114. })}
  115. onClick={() => {
  116. props.onStepChange?.(i);
  117. }}
  118. role="button"
  119. >
  120. <span className={styles["step-index"]}>{i + 1}</span>
  121. <span className={styles["step-name"]}>{step.name}</span>
  122. </div>
  123. );
  124. })}
  125. </div>
  126. </div>
  127. );
  128. }
  129. export function MessageExporter() {
  130. const steps = [
  131. {
  132. name: Locale.Export.Steps.Select,
  133. value: "select",
  134. },
  135. {
  136. name: Locale.Export.Steps.Preview,
  137. value: "preview",
  138. },
  139. ];
  140. const { currentStep, setCurrentStepIndex, currentStepIndex } =
  141. useSteps(steps);
  142. const formats = ["text", "image", "json"] as const;
  143. type ExportFormat = (typeof formats)[number];
  144. const [exportConfig, setExportConfig] = useState({
  145. format: "image" as ExportFormat,
  146. includeContext: true,
  147. });
  148. function updateExportConfig(updater: (config: typeof exportConfig) => void) {
  149. const config = { ...exportConfig };
  150. updater(config);
  151. setExportConfig(config);
  152. }
  153. const chatStore = useChatStore();
  154. const session = chatStore.currentSession();
  155. const { selection, updateSelection } = useMessageSelector();
  156. const selectedMessages = useMemo(() => {
  157. const ret: ChatMessage[] = [];
  158. if (exportConfig.includeContext) {
  159. ret.push(...session.mask.context);
  160. }
  161. ret.push(...session.messages.filter((m) => selection.has(m.id)));
  162. return ret;
  163. }, [
  164. exportConfig.includeContext,
  165. session.messages,
  166. session.mask.context,
  167. selection,
  168. ]);
  169. function preview() {
  170. if (exportConfig.format === "text") {
  171. return (
  172. <MarkdownPreviewer messages={selectedMessages} topic={session.topic} />
  173. );
  174. } else if (exportConfig.format === "json") {
  175. return (
  176. <JsonPreviewer messages={selectedMessages} topic={session.topic} />
  177. );
  178. } else {
  179. return (
  180. <ImagePreviewer messages={selectedMessages} topic={session.topic} />
  181. );
  182. }
  183. }
  184. return (
  185. <>
  186. <Steps
  187. steps={steps}
  188. index={currentStepIndex}
  189. onStepChange={setCurrentStepIndex}
  190. />
  191. <div
  192. className={styles["message-exporter-body"]}
  193. style={currentStep.value !== "select" ? { display: "none" } : {}}
  194. >
  195. <List>
  196. <ListItem
  197. title={Locale.Export.Format.Title}
  198. subTitle={Locale.Export.Format.SubTitle}
  199. >
  200. <Select
  201. value={exportConfig.format}
  202. onChange={(e) =>
  203. updateExportConfig(
  204. (config) =>
  205. (config.format = e.currentTarget.value as ExportFormat),
  206. )
  207. }
  208. >
  209. {formats.map((f) => (
  210. <option key={f} value={f}>
  211. {f}
  212. </option>
  213. ))}
  214. </Select>
  215. </ListItem>
  216. <ListItem
  217. title={Locale.Export.IncludeContext.Title}
  218. subTitle={Locale.Export.IncludeContext.SubTitle}
  219. >
  220. <input
  221. type="checkbox"
  222. checked={exportConfig.includeContext}
  223. onChange={(e) => {
  224. updateExportConfig(
  225. (config) => (config.includeContext = e.currentTarget.checked),
  226. );
  227. }}
  228. ></input>
  229. </ListItem>
  230. </List>
  231. <MessageSelector
  232. selection={selection}
  233. updateSelection={updateSelection}
  234. defaultSelectAll
  235. />
  236. </div>
  237. {currentStep.value === "preview" && (
  238. <div className={styles["message-exporter-body"]}>{preview()}</div>
  239. )}
  240. </>
  241. );
  242. }
  243. export function RenderExport(props: {
  244. messages: ChatMessage[];
  245. onRender: (messages: ChatMessage[]) => void;
  246. }) {
  247. const domRef = useRef<HTMLDivElement>(null);
  248. useEffect(() => {
  249. if (!domRef.current) return;
  250. const dom = domRef.current;
  251. const messages = Array.from(
  252. dom.getElementsByClassName(EXPORT_MESSAGE_CLASS_NAME),
  253. );
  254. if (messages.length !== props.messages.length) {
  255. return;
  256. }
  257. const renderMsgs = messages.map((v, i) => {
  258. const [role, _] = v.id.split(":");
  259. return {
  260. id: i.toString(),
  261. role: role as any,
  262. content: role === "user" ? v.textContent ?? "" : v.innerHTML,
  263. date: "",
  264. };
  265. });
  266. props.onRender(renderMsgs);
  267. // eslint-disable-next-line react-hooks/exhaustive-deps
  268. }, []);
  269. return (
  270. <div ref={domRef}>
  271. {props.messages.map((m, i) => (
  272. <div
  273. key={i}
  274. id={`${m.role}:${i}`}
  275. className={EXPORT_MESSAGE_CLASS_NAME}
  276. >
  277. <Markdown content={getMessageTextContent(m)} defaultShow />
  278. </div>
  279. ))}
  280. </div>
  281. );
  282. }
  283. export function PreviewActions(props: {
  284. download: () => void;
  285. copy: () => void;
  286. showCopy?: boolean;
  287. messages?: ChatMessage[];
  288. }) {
  289. const [loading, setLoading] = useState(false);
  290. const [shouldExport, setShouldExport] = useState(false);
  291. const config = useAppConfig();
  292. const onRenderMsgs = (msgs: ChatMessage[]) => {
  293. setShouldExport(false);
  294. const api: ClientApi = getClientApi(config.modelConfig.providerName);
  295. api
  296. .share(msgs)
  297. .then((res) => {
  298. if (!res) return;
  299. showModal({
  300. title: Locale.Export.Share,
  301. children: [
  302. <input
  303. type="text"
  304. value={res}
  305. key="input"
  306. style={{
  307. width: "100%",
  308. maxWidth: "unset",
  309. }}
  310. readOnly
  311. onClick={(e) => e.currentTarget.select()}
  312. ></input>,
  313. ],
  314. actions: [
  315. <IconButton
  316. icon={<CopyIcon />}
  317. text={Locale.Chat.Actions.Copy}
  318. key="copy"
  319. onClick={() => copyToClipboard(res)}
  320. />,
  321. ],
  322. });
  323. setTimeout(() => {
  324. window.open(res, "_blank");
  325. }, 800);
  326. })
  327. .catch((e) => {
  328. console.error("[Share]", e);
  329. showToast(prettyObject(e));
  330. })
  331. .finally(() => setLoading(false));
  332. };
  333. const share = async () => {
  334. if (props.messages?.length) {
  335. setLoading(true);
  336. setShouldExport(true);
  337. }
  338. };
  339. return (
  340. <>
  341. <div className={styles["preview-actions"]}>
  342. {props.showCopy && (
  343. <IconButton
  344. text={Locale.Export.Copy}
  345. bordered
  346. shadow
  347. icon={<CopyIcon />}
  348. onClick={props.copy}
  349. ></IconButton>
  350. )}
  351. <IconButton
  352. text={Locale.Export.Download}
  353. bordered
  354. shadow
  355. icon={<DownloadIcon />}
  356. onClick={props.download}
  357. ></IconButton>
  358. <IconButton
  359. text={Locale.Export.Share}
  360. bordered
  361. shadow
  362. icon={loading ? <LoadingIcon /> : <ShareIcon />}
  363. onClick={share}
  364. ></IconButton>
  365. </div>
  366. <div
  367. style={{
  368. position: "fixed",
  369. right: "200vw",
  370. pointerEvents: "none",
  371. }}
  372. >
  373. {shouldExport && (
  374. <RenderExport
  375. messages={props.messages ?? []}
  376. onRender={onRenderMsgs}
  377. />
  378. )}
  379. </div>
  380. </>
  381. );
  382. }
  383. export function ImagePreviewer(props: {
  384. messages: ChatMessage[];
  385. topic: string;
  386. }) {
  387. const chatStore = useChatStore();
  388. const session = chatStore.currentSession();
  389. const mask = session.mask;
  390. const config = useAppConfig();
  391. const previewRef = useRef<HTMLDivElement>(null);
  392. const copy = () => {
  393. showToast(Locale.Export.Image.Toast);
  394. const dom = previewRef.current;
  395. if (!dom) return;
  396. toBlob(dom).then((blob) => {
  397. if (!blob) return;
  398. try {
  399. navigator.clipboard
  400. .write([
  401. new ClipboardItem({
  402. "image/png": blob,
  403. }),
  404. ])
  405. .then(() => {
  406. showToast(Locale.Copy.Success);
  407. refreshPreview();
  408. });
  409. } catch (e) {
  410. console.error("[Copy Image] ", e);
  411. showToast(Locale.Copy.Failed);
  412. }
  413. });
  414. };
  415. const isMobile = useMobileScreen();
  416. const download = async () => {
  417. showToast(Locale.Export.Image.Toast);
  418. const dom = previewRef.current;
  419. if (!dom) return;
  420. const isApp = getClientConfig()?.isApp;
  421. try {
  422. const blob = await toPng(dom);
  423. if (!blob) return;
  424. if (isMobile || (isApp && window.__TAURI__)) {
  425. if (isApp && window.__TAURI__) {
  426. const result = await window.__TAURI__.dialog.save({
  427. defaultPath: `${props.topic}.png`,
  428. filters: [
  429. {
  430. name: "PNG Files",
  431. extensions: ["png"],
  432. },
  433. {
  434. name: "All Files",
  435. extensions: ["*"],
  436. },
  437. ],
  438. });
  439. if (result !== null) {
  440. const response = await fetch(blob);
  441. const buffer = await response.arrayBuffer();
  442. const uint8Array = new Uint8Array(buffer);
  443. await window.__TAURI__.fs.writeBinaryFile(result, uint8Array);
  444. showToast(Locale.Download.Success);
  445. } else {
  446. showToast(Locale.Download.Failed);
  447. }
  448. } else {
  449. showImageModal(blob);
  450. }
  451. } else {
  452. const link = document.createElement("a");
  453. link.download = `${props.topic}.png`;
  454. link.href = blob;
  455. link.click();
  456. refreshPreview();
  457. }
  458. } catch (error) {
  459. showToast(Locale.Download.Failed);
  460. }
  461. };
  462. const refreshPreview = () => {
  463. const dom = previewRef.current;
  464. if (dom) {
  465. dom.innerHTML = dom.innerHTML; // Refresh the content of the preview by resetting its HTML for fix a bug glitching
  466. }
  467. };
  468. return (
  469. <div className={styles["image-previewer"]}>
  470. <PreviewActions
  471. copy={copy}
  472. download={download}
  473. showCopy={!isMobile}
  474. messages={props.messages}
  475. />
  476. <div
  477. className={clsx(styles["preview-body"], styles["default-theme"])}
  478. ref={previewRef}
  479. >
  480. <div className={styles["chat-info"]}>
  481. <div className={clsx(styles["logo"], "no-dark")}>
  482. <NextImage
  483. src={ChatGptIcon.src}
  484. alt="logo"
  485. width={50}
  486. height={50}
  487. />
  488. </div>
  489. <div>
  490. <div className={styles["main-title"]}>NextChat</div>
  491. <div className={styles["sub-title"]}>
  492. github.com/ChatGPTNextWeb/ChatGPT-Next-Web
  493. </div>
  494. <div className={styles["icons"]}>
  495. <Avatar />
  496. <span className={styles["icon-space"]}>&</span>
  497. <MaskAvatar
  498. avatar={mask.avatar}
  499. model={session.mask.modelConfig.model}
  500. />
  501. </div>
  502. </div>
  503. <div>
  504. <div className={styles["chat-info-item"]}>
  505. {Locale.Exporter.Model}: {mask.modelConfig.model}
  506. </div>
  507. <div className={styles["chat-info-item"]}>
  508. {Locale.Exporter.Messages}: {props.messages.length}
  509. </div>
  510. <div className={styles["chat-info-item"]}>
  511. {Locale.Exporter.Topic}: {session.topic}
  512. </div>
  513. <div className={styles["chat-info-item"]}>
  514. {Locale.Exporter.Time}:{" "}
  515. {new Date(
  516. props.messages.at(-1)?.date ?? Date.now(),
  517. ).toLocaleString()}
  518. </div>
  519. </div>
  520. </div>
  521. {props.messages.map((m, i) => {
  522. return (
  523. <div
  524. className={clsx(styles["message"], styles["message-" + m.role])}
  525. key={i}
  526. >
  527. <div className={styles["avatar"]}>
  528. {m.role === "user" ? (
  529. <Avatar></Avatar>
  530. ) : (
  531. <MaskAvatar
  532. avatar={session.mask.avatar}
  533. model={m.model || session.mask.modelConfig.model}
  534. />
  535. )}
  536. </div>
  537. <div className={styles["body"]}>
  538. <Markdown
  539. content={getMessageTextContent(m)}
  540. fontSize={config.fontSize}
  541. fontFamily={config.fontFamily}
  542. defaultShow
  543. />
  544. {getMessageImages(m).length == 1 && (
  545. <img
  546. key={i}
  547. src={getMessageImages(m)[0]}
  548. alt="message"
  549. className={styles["message-image"]}
  550. />
  551. )}
  552. {getMessageImages(m).length > 1 && (
  553. <div
  554. className={styles["message-images"]}
  555. style={
  556. {
  557. "--image-count": getMessageImages(m).length,
  558. } as React.CSSProperties
  559. }
  560. >
  561. {getMessageImages(m).map((src, i) => (
  562. <img
  563. key={i}
  564. src={src}
  565. alt="message"
  566. className={styles["message-image-multi"]}
  567. />
  568. ))}
  569. </div>
  570. )}
  571. </div>
  572. </div>
  573. );
  574. })}
  575. </div>
  576. </div>
  577. );
  578. }
  579. export function MarkdownPreviewer(props: {
  580. messages: ChatMessage[];
  581. topic: string;
  582. }) {
  583. const mdText =
  584. `# ${props.topic}\n\n` +
  585. props.messages
  586. .map((m) => {
  587. return m.role === "user"
  588. ? `## ${Locale.Export.MessageFromYou}:\n${getMessageTextContent(m)}`
  589. : `## ${Locale.Export.MessageFromChatGPT}:\n${getMessageTextContent(
  590. m,
  591. ).trim()}`;
  592. })
  593. .join("\n\n");
  594. const copy = () => {
  595. copyToClipboard(mdText);
  596. };
  597. const download = () => {
  598. downloadAs(mdText, `${props.topic}.md`);
  599. };
  600. return (
  601. <>
  602. <PreviewActions
  603. copy={copy}
  604. download={download}
  605. showCopy={true}
  606. messages={props.messages}
  607. />
  608. <div className="markdown-body">
  609. <pre className={styles["export-content"]}>{mdText}</pre>
  610. </div>
  611. </>
  612. );
  613. }
  614. export function JsonPreviewer(props: {
  615. messages: ChatMessage[];
  616. topic: string;
  617. }) {
  618. const msgs = {
  619. messages: [
  620. {
  621. role: "system",
  622. content: `${Locale.FineTuned.Sysmessage} ${props.topic}`,
  623. },
  624. ...props.messages.map((m) => ({
  625. role: m.role,
  626. content: m.content,
  627. })),
  628. ],
  629. };
  630. const mdText = "```json\n" + JSON.stringify(msgs, null, 2) + "\n```";
  631. const minifiedJson = JSON.stringify(msgs);
  632. const copy = () => {
  633. copyToClipboard(minifiedJson);
  634. };
  635. const download = () => {
  636. downloadAs(JSON.stringify(msgs), `${props.topic}.json`);
  637. };
  638. return (
  639. <>
  640. <PreviewActions
  641. copy={copy}
  642. download={download}
  643. showCopy={false}
  644. messages={props.messages}
  645. />
  646. <div className="markdown-body" onClick={copy}>
  647. <Markdown content={mdText} />
  648. </div>
  649. </>
  650. );
  651. }