exporter.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. /* eslint-disable @next/next/no-img-element */
  2. import { ChatMessage, ModelType, 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 BotIcon from "../icons/bot.png";
  26. import DownloadIcon from "../icons/download.svg";
  27. import { useEffect, useMemo, useRef, useState } from "react";
  28. import { MessageSelector, useMessageSelector } from "./message-selector";
  29. import { Avatar } from "./emoji";
  30. import dynamic from "next/dynamic";
  31. import NextImage from "next/image";
  32. import { toBlob, toPng } from "html-to-image";
  33. import { DEFAULT_MASK_AVATAR } from "../store/mask";
  34. import { prettyObject } from "../utils/format";
  35. import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant";
  36. import { getClientConfig } from "../config/client";
  37. import { ClientApi } from "../client/api";
  38. import { getMessageTextContent } from "../utils";
  39. import { identifyDefaultClaudeModel } from "../utils/checkers";
  40. const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
  41. loading: () => <LoadingIcon />,
  42. });
  43. export function ExportMessageModal(props: { onClose: () => void }) {
  44. return (
  45. <div className="modal-mask">
  46. <Modal
  47. title={Locale.Export.Title}
  48. onClose={props.onClose}
  49. footer={
  50. <div
  51. style={{
  52. width: "100%",
  53. textAlign: "center",
  54. fontSize: 14,
  55. opacity: 0.5,
  56. }}
  57. >
  58. {Locale.Exporter.Description.Title}
  59. </div>
  60. }
  61. >
  62. <div style={{ minHeight: "40vh" }}>
  63. <MessageExporter />
  64. </div>
  65. </Modal>
  66. </div>
  67. );
  68. }
  69. function useSteps(
  70. steps: Array<{
  71. name: string;
  72. value: string;
  73. }>,
  74. ) {
  75. const stepCount = steps.length;
  76. const [currentStepIndex, setCurrentStepIndex] = useState(0);
  77. const nextStep = () =>
  78. setCurrentStepIndex((currentStepIndex + 1) % stepCount);
  79. const prevStep = () =>
  80. setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
  81. return {
  82. currentStepIndex,
  83. setCurrentStepIndex,
  84. nextStep,
  85. prevStep,
  86. currentStep: steps[currentStepIndex],
  87. };
  88. }
  89. function Steps<
  90. T extends {
  91. name: string;
  92. value: string;
  93. }[],
  94. >(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
  95. const steps = props.steps;
  96. const stepCount = steps.length;
  97. return (
  98. <div className={styles["steps"]}>
  99. <div className={styles["steps-progress"]}>
  100. <div
  101. className={styles["steps-progress-inner"]}
  102. style={{
  103. width: `${((props.index + 1) / stepCount) * 100}%`,
  104. }}
  105. ></div>
  106. </div>
  107. <div className={styles["steps-inner"]}>
  108. {steps.map((step, i) => {
  109. return (
  110. <div
  111. key={i}
  112. className={`${styles["step"]} ${
  113. styles[i <= props.index ? "step-finished" : ""]
  114. } ${i === props.index && styles["step-current"]} clickable`}
  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. var api: ClientApi;
  295. if (config.modelConfig.model.startsWith("gemini")) {
  296. api = new ClientApi(ModelProvider.GeminiPro);
  297. } else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
  298. api = new ClientApi(ModelProvider.Claude);
  299. } else {
  300. api = new ClientApi(ModelProvider.GPT);
  301. }
  302. api
  303. .share(msgs)
  304. .then((res) => {
  305. if (!res) return;
  306. showModal({
  307. title: Locale.Export.Share,
  308. children: [
  309. <input
  310. type="text"
  311. value={res}
  312. key="input"
  313. style={{
  314. width: "100%",
  315. maxWidth: "unset",
  316. }}
  317. readOnly
  318. onClick={(e) => e.currentTarget.select()}
  319. ></input>,
  320. ],
  321. actions: [
  322. <IconButton
  323. icon={<CopyIcon />}
  324. text={Locale.Chat.Actions.Copy}
  325. key="copy"
  326. onClick={() => copyToClipboard(res)}
  327. />,
  328. ],
  329. });
  330. setTimeout(() => {
  331. window.open(res, "_blank");
  332. }, 800);
  333. })
  334. .catch((e) => {
  335. console.error("[Share]", e);
  336. showToast(prettyObject(e));
  337. })
  338. .finally(() => setLoading(false));
  339. };
  340. const share = async () => {
  341. if (props.messages?.length) {
  342. setLoading(true);
  343. setShouldExport(true);
  344. }
  345. };
  346. return (
  347. <>
  348. <div className={styles["preview-actions"]}>
  349. {props.showCopy && (
  350. <IconButton
  351. text={Locale.Export.Copy}
  352. bordered
  353. shadow
  354. icon={<CopyIcon />}
  355. onClick={props.copy}
  356. ></IconButton>
  357. )}
  358. <IconButton
  359. text={Locale.Export.Download}
  360. bordered
  361. shadow
  362. icon={<DownloadIcon />}
  363. onClick={props.download}
  364. ></IconButton>
  365. <IconButton
  366. text={Locale.Export.Share}
  367. bordered
  368. shadow
  369. icon={loading ? <LoadingIcon /> : <ShareIcon />}
  370. onClick={share}
  371. ></IconButton>
  372. </div>
  373. <div
  374. style={{
  375. position: "fixed",
  376. right: "200vw",
  377. pointerEvents: "none",
  378. }}
  379. >
  380. {shouldExport && (
  381. <RenderExport
  382. messages={props.messages ?? []}
  383. onRender={onRenderMsgs}
  384. />
  385. )}
  386. </div>
  387. </>
  388. );
  389. }
  390. function ExportAvatar(props: { avatar: string }) {
  391. if (props.avatar === DEFAULT_MASK_AVATAR) {
  392. return (
  393. <img
  394. src={BotIcon.src}
  395. width={30}
  396. height={30}
  397. alt="bot"
  398. className="user-avatar"
  399. />
  400. );
  401. }
  402. return <Avatar avatar={props.avatar} />;
  403. }
  404. export function ImagePreviewer(props: {
  405. messages: ChatMessage[];
  406. topic: string;
  407. }) {
  408. const chatStore = useChatStore();
  409. const session = chatStore.currentSession();
  410. const mask = session.mask;
  411. const config = useAppConfig();
  412. const previewRef = useRef<HTMLDivElement>(null);
  413. const copy = () => {
  414. showToast(Locale.Export.Image.Toast);
  415. const dom = previewRef.current;
  416. if (!dom) return;
  417. toBlob(dom).then((blob) => {
  418. if (!blob) return;
  419. try {
  420. navigator.clipboard
  421. .write([
  422. new ClipboardItem({
  423. "image/png": blob,
  424. }),
  425. ])
  426. .then(() => {
  427. showToast(Locale.Copy.Success);
  428. refreshPreview();
  429. });
  430. } catch (e) {
  431. console.error("[Copy Image] ", e);
  432. showToast(Locale.Copy.Failed);
  433. }
  434. });
  435. };
  436. const isMobile = useMobileScreen();
  437. const download = async () => {
  438. showToast(Locale.Export.Image.Toast);
  439. const dom = previewRef.current;
  440. if (!dom) return;
  441. const isApp = getClientConfig()?.isApp;
  442. try {
  443. const blob = await toPng(dom);
  444. if (!blob) return;
  445. if (isMobile || (isApp && window.__TAURI__)) {
  446. if (isApp && window.__TAURI__) {
  447. const result = await window.__TAURI__.dialog.save({
  448. defaultPath: `${props.topic}.png`,
  449. filters: [
  450. {
  451. name: "PNG Files",
  452. extensions: ["png"],
  453. },
  454. {
  455. name: "All Files",
  456. extensions: ["*"],
  457. },
  458. ],
  459. });
  460. if (result !== null) {
  461. const response = await fetch(blob);
  462. const buffer = await response.arrayBuffer();
  463. const uint8Array = new Uint8Array(buffer);
  464. await window.__TAURI__.fs.writeBinaryFile(result, uint8Array);
  465. showToast(Locale.Download.Success);
  466. } else {
  467. showToast(Locale.Download.Failed);
  468. }
  469. } else {
  470. showImageModal(blob);
  471. }
  472. } else {
  473. const link = document.createElement("a");
  474. link.download = `${props.topic}.png`;
  475. link.href = blob;
  476. link.click();
  477. refreshPreview();
  478. }
  479. } catch (error) {
  480. showToast(Locale.Download.Failed);
  481. }
  482. };
  483. const refreshPreview = () => {
  484. const dom = previewRef.current;
  485. if (dom) {
  486. dom.innerHTML = dom.innerHTML; // Refresh the content of the preview by resetting its HTML for fix a bug glitching
  487. }
  488. };
  489. return (
  490. <div className={styles["image-previewer"]}>
  491. <PreviewActions
  492. copy={copy}
  493. download={download}
  494. showCopy={!isMobile}
  495. messages={props.messages}
  496. />
  497. <div
  498. className={`${styles["preview-body"]} ${styles["default-theme"]}`}
  499. ref={previewRef}
  500. >
  501. <div className={styles["chat-info"]}>
  502. <div className={styles["logo"] + " no-dark"}>
  503. <NextImage
  504. src={ChatGptIcon.src}
  505. alt="logo"
  506. width={50}
  507. height={50}
  508. />
  509. </div>
  510. <div>
  511. <div className={styles["main-title"]}>NextChat</div>
  512. <div className={styles["sub-title"]}>
  513. github.com/Yidadaa/ChatGPT-Next-Web
  514. </div>
  515. <div className={styles["icons"]}>
  516. <ExportAvatar avatar={config.avatar} />
  517. <span className={styles["icon-space"]}>&</span>
  518. <ExportAvatar avatar={mask.avatar} />
  519. </div>
  520. </div>
  521. <div>
  522. <div className={styles["chat-info-item"]}>
  523. {Locale.Exporter.Model}: {mask.modelConfig.model}
  524. </div>
  525. <div className={styles["chat-info-item"]}>
  526. {Locale.Exporter.Messages}: {props.messages.length}
  527. </div>
  528. <div className={styles["chat-info-item"]}>
  529. {Locale.Exporter.Topic}: {session.topic}
  530. </div>
  531. <div className={styles["chat-info-item"]}>
  532. {Locale.Exporter.Time}:{" "}
  533. {new Date(
  534. props.messages.at(-1)?.date ?? Date.now(),
  535. ).toLocaleString()}
  536. </div>
  537. </div>
  538. </div>
  539. {props.messages.map((m, i) => {
  540. return (
  541. <div
  542. className={styles["message"] + " " + styles["message-" + m.role]}
  543. key={i}
  544. >
  545. <div className={styles["avatar"]}>
  546. <ExportAvatar
  547. avatar={m.role === "user" ? config.avatar : mask.avatar}
  548. />
  549. </div>
  550. <div className={styles["body"]}>
  551. <Markdown
  552. content={getMessageTextContent(m)}
  553. fontSize={config.fontSize}
  554. defaultShow
  555. />
  556. {getMessageImages(m).length == 1 && (
  557. <img
  558. key={i}
  559. src={getMessageImages(m)[0]}
  560. alt="message"
  561. className={styles["message-image"]}
  562. />
  563. )}
  564. {getMessageImages(m).length > 1 && (
  565. <div
  566. className={styles["message-images"]}
  567. style={
  568. {
  569. "--image-count": getMessageImages(m).length,
  570. } as React.CSSProperties
  571. }
  572. >
  573. {getMessageImages(m).map((src, i) => (
  574. <img
  575. key={i}
  576. src={src}
  577. alt="message"
  578. className={styles["message-image-multi"]}
  579. />
  580. ))}
  581. </div>
  582. )}
  583. </div>
  584. </div>
  585. );
  586. })}
  587. </div>
  588. </div>
  589. );
  590. }
  591. export function MarkdownPreviewer(props: {
  592. messages: ChatMessage[];
  593. topic: string;
  594. }) {
  595. const mdText =
  596. `# ${props.topic}\n\n` +
  597. props.messages
  598. .map((m) => {
  599. return m.role === "user"
  600. ? `## ${Locale.Export.MessageFromYou}:\n${getMessageTextContent(m)}`
  601. : `## ${Locale.Export.MessageFromChatGPT}:\n${getMessageTextContent(
  602. m,
  603. ).trim()}`;
  604. })
  605. .join("\n\n");
  606. const copy = () => {
  607. copyToClipboard(mdText);
  608. };
  609. const download = () => {
  610. downloadAs(mdText, `${props.topic}.md`);
  611. };
  612. return (
  613. <>
  614. <PreviewActions
  615. copy={copy}
  616. download={download}
  617. showCopy={true}
  618. messages={props.messages}
  619. />
  620. <div className="markdown-body">
  621. <pre className={styles["export-content"]}>{mdText}</pre>
  622. </div>
  623. </>
  624. );
  625. }
  626. export function JsonPreviewer(props: {
  627. messages: ChatMessage[];
  628. topic: string;
  629. }) {
  630. const msgs = {
  631. messages: [
  632. {
  633. role: "system",
  634. content: `${Locale.FineTuned.Sysmessage} ${props.topic}`,
  635. },
  636. ...props.messages.map((m) => ({
  637. role: m.role,
  638. content: m.content,
  639. })),
  640. ],
  641. };
  642. const mdText = "```json\n" + JSON.stringify(msgs, null, 2) + "\n```";
  643. const minifiedJson = JSON.stringify(msgs);
  644. const copy = () => {
  645. copyToClipboard(minifiedJson);
  646. };
  647. const download = () => {
  648. downloadAs(JSON.stringify(msgs), `${props.topic}.json`);
  649. };
  650. return (
  651. <>
  652. <PreviewActions
  653. copy={copy}
  654. download={download}
  655. showCopy={false}
  656. messages={props.messages}
  657. />
  658. <div className="markdown-body" onClick={copy}>
  659. <Markdown content={mdText} />
  660. </div>
  661. </>
  662. );
  663. }