exporter.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  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
  142. className={styles["message-exporter-body"]}
  143. style={currentStep.value !== "select" ? { display: "none" } : {}}
  144. >
  145. <List>
  146. <ListItem
  147. title={Locale.Export.Format.Title}
  148. subTitle={Locale.Export.Format.SubTitle}
  149. >
  150. <Select
  151. value={exportConfig.format}
  152. onChange={(e) =>
  153. updateExportConfig(
  154. (config) =>
  155. (config.format = e.currentTarget.value as ExportFormat),
  156. )
  157. }
  158. >
  159. {formats.map((f) => (
  160. <option key={f} value={f}>
  161. {f}
  162. </option>
  163. ))}
  164. </Select>
  165. </ListItem>
  166. <ListItem
  167. title={Locale.Export.IncludeContext.Title}
  168. subTitle={Locale.Export.IncludeContext.SubTitle}
  169. >
  170. <input
  171. type="checkbox"
  172. checked={exportConfig.includeContext}
  173. onChange={(e) => {
  174. updateExportConfig(
  175. (config) => (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. </div>
  187. {currentStep.value === "preview" && (
  188. <div className={styles["message-exporter-body"]}>
  189. {exportConfig.format === "text" ? (
  190. <MarkdownPreviewer
  191. messages={selectedMessages}
  192. topic={session.topic}
  193. />
  194. ) : (
  195. <ImagePreviewer messages={selectedMessages} topic={session.topic} />
  196. )}
  197. </div>
  198. )}
  199. </>
  200. );
  201. }
  202. export function PreviewActions(props: {
  203. download: () => void;
  204. copy: () => void;
  205. showCopy?: boolean;
  206. }) {
  207. return (
  208. <div className={styles["preview-actions"]}>
  209. {props.showCopy && (
  210. <IconButton
  211. text={Locale.Export.Copy}
  212. bordered
  213. shadow
  214. icon={<CopyIcon />}
  215. onClick={props.copy}
  216. ></IconButton>
  217. )}
  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. function ExportAvatar(props: { avatar: string }) {
  236. if (props.avatar === DEFAULT_MASK_AVATAR) {
  237. return (
  238. <NextImage
  239. src={BotIcon.src}
  240. width={30}
  241. height={30}
  242. alt="bot"
  243. className="user-avatar"
  244. />
  245. );
  246. }
  247. return <Avatar avatar={props.avatar}></Avatar>;
  248. }
  249. export function ImagePreviewer(props: {
  250. messages: ChatMessage[];
  251. topic: string;
  252. }) {
  253. const chatStore = useChatStore();
  254. const session = chatStore.currentSession();
  255. const mask = session.mask;
  256. const config = useAppConfig();
  257. const previewRef = useRef<HTMLDivElement>(null);
  258. const copy = () => {
  259. const dom = previewRef.current;
  260. if (!dom) return;
  261. toBlob(dom).then((blob) => {
  262. if (!blob) return;
  263. try {
  264. navigator.clipboard
  265. .write([
  266. new ClipboardItem({
  267. "image/png": blob,
  268. }),
  269. ])
  270. .then(() => {
  271. showToast(Locale.Copy.Success);
  272. });
  273. } catch (e) {
  274. console.error("[Copy Image] ", e);
  275. showToast(Locale.Copy.Failed);
  276. }
  277. });
  278. };
  279. const isMobile = useMobileScreen();
  280. const download = () => {
  281. const dom = previewRef.current;
  282. if (!dom) return;
  283. toPng(dom)
  284. .then((blob) => {
  285. if (!blob) return;
  286. if (isMobile) {
  287. const image = new Image();
  288. image.src = blob;
  289. const win = window.open("");
  290. win?.document.write(image.outerHTML);
  291. } else {
  292. const link = document.createElement("a");
  293. link.download = `${props.topic}.png`;
  294. link.href = blob;
  295. link.click();
  296. }
  297. })
  298. .catch((e) => console.log("[Export Image] ", e));
  299. };
  300. return (
  301. <div className={styles["image-previewer"]}>
  302. <PreviewActions copy={copy} download={download} showCopy={!isMobile} />
  303. <div
  304. className={`${styles["preview-body"]} ${styles["default-theme"]}`}
  305. ref={previewRef}
  306. >
  307. <div className={styles["chat-info"]}>
  308. <div className={styles["logo"] + " no-dark"}>
  309. <NextImage
  310. src={ChatGptIcon.src}
  311. alt="logo"
  312. width={50}
  313. height={50}
  314. />
  315. </div>
  316. <div>
  317. <div className={styles["main-title"]}>ChatGPT Next Web</div>
  318. <div className={styles["sub-title"]}>
  319. github.com/Yidadaa/ChatGPT-Next-Web
  320. </div>
  321. <div className={styles["icons"]}>
  322. <ExportAvatar avatar={config.avatar} />
  323. <span className={styles["icon-space"]}>&</span>
  324. <ExportAvatar avatar={mask.avatar} />
  325. </div>
  326. </div>
  327. <div>
  328. <div className={styles["chat-info-item"]}>
  329. Model: {mask.modelConfig.model}
  330. </div>
  331. <div className={styles["chat-info-item"]}>
  332. Messages: {props.messages.length}
  333. </div>
  334. <div className={styles["chat-info-item"]}>
  335. Topic: {session.topic}
  336. </div>
  337. <div className={styles["chat-info-item"]}>
  338. Time:{" "}
  339. {new Date(
  340. props.messages.at(-1)?.date ?? Date.now(),
  341. ).toLocaleString()}
  342. </div>
  343. </div>
  344. </div>
  345. {props.messages.map((m, i) => {
  346. return (
  347. <div
  348. className={styles["message"] + " " + styles["message-" + m.role]}
  349. key={i}
  350. >
  351. <div className={styles["avatar"]}>
  352. <ExportAvatar
  353. avatar={m.role === "user" ? config.avatar : mask.avatar}
  354. />
  355. </div>
  356. <div className={styles["body"]}>
  357. <Markdown
  358. content={m.content}
  359. fontSize={config.fontSize}
  360. defaultShow
  361. />
  362. </div>
  363. </div>
  364. );
  365. })}
  366. </div>
  367. </div>
  368. );
  369. }
  370. export function MarkdownPreviewer(props: {
  371. messages: ChatMessage[];
  372. topic: string;
  373. }) {
  374. const mdText =
  375. `# ${props.topic}\n\n` +
  376. props.messages
  377. .map((m) => {
  378. return m.role === "user"
  379. ? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
  380. : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
  381. })
  382. .join("\n\n");
  383. const copy = () => {
  384. copyToClipboard(mdText);
  385. };
  386. const download = () => {
  387. downloadAs(mdText, `${props.topic}.md`);
  388. };
  389. return (
  390. <>
  391. <PreviewActions copy={copy} download={download} />
  392. <div className="markdown-body">
  393. <pre className={styles["export-content"]}>{mdText}</pre>
  394. </div>
  395. </>
  396. );
  397. }