123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662 |
- /* eslint-disable @next/next/no-img-element */
- import { ChatMessage, ModelType, useAppConfig, useChatStore } from "../store";
- import Locale from "../locales";
- import styles from "./exporter.module.scss";
- import {
- List,
- ListItem,
- Modal,
- Select,
- showImageModal,
- showModal,
- showToast,
- } from "./ui-lib";
- import { IconButton } from "./button";
- import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
- import CopyIcon from "../icons/copy.svg";
- import LoadingIcon from "../icons/three-dots.svg";
- import ChatGptIcon from "../icons/chatgpt.png";
- import ShareIcon from "../icons/share.svg";
- import BotIcon from "../icons/bot.png";
- import DownloadIcon from "../icons/download.svg";
- import { useEffect, useMemo, useRef, useState } from "react";
- import { MessageSelector, useMessageSelector } from "./message-selector";
- import { Avatar } from "./emoji";
- import dynamic from "next/dynamic";
- import NextImage from "next/image";
- import { toBlob, toPng } from "html-to-image";
- import { DEFAULT_MASK_AVATAR } from "../store/mask";
- import { api } from "../client/api";
- import { prettyObject } from "../utils/format";
- import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
- import { getClientConfig } from "../config/client";
- const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
- loading: () => <LoadingIcon />,
- });
- export function ExportMessageModal(props: { onClose: () => void }) {
- return (
- <div className="modal-mask">
- <Modal
- title={Locale.Export.Title}
- onClose={props.onClose}
- footer={
- <div
- style={{
- width: "100%",
- textAlign: "center",
- fontSize: 14,
- opacity: 0.5,
- }}
- >
- {Locale.Exporter.Description.Title}
- </div>
- }
- >
- <div style={{ minHeight: "40vh" }}>
- <MessageExporter />
- </div>
- </Modal>
- </div>
- );
- }
- function useSteps(
- steps: Array<{
- name: string;
- value: string;
- }>,
- ) {
- const stepCount = steps.length;
- const [currentStepIndex, setCurrentStepIndex] = useState(0);
- const nextStep = () =>
- setCurrentStepIndex((currentStepIndex + 1) % stepCount);
- const prevStep = () =>
- setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
- return {
- currentStepIndex,
- setCurrentStepIndex,
- nextStep,
- prevStep,
- currentStep: steps[currentStepIndex],
- };
- }
- function Steps<
- T extends {
- name: string;
- value: string;
- }[],
- >(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
- const steps = props.steps;
- const stepCount = steps.length;
- return (
- <div className={styles["steps"]}>
- <div className={styles["steps-progress"]}>
- <div
- className={styles["steps-progress-inner"]}
- style={{
- width: `${((props.index + 1) / stepCount) * 100}%`,
- }}
- ></div>
- </div>
- <div className={styles["steps-inner"]}>
- {steps.map((step, i) => {
- return (
- <div
- key={i}
- className={`${styles["step"]} ${
- styles[i <= props.index ? "step-finished" : ""]
- } ${i === props.index && styles["step-current"]} clickable`}
- onClick={() => {
- props.onStepChange?.(i);
- }}
- role="button"
- >
- <span className={styles["step-index"]}>{i + 1}</span>
- <span className={styles["step-name"]}>{step.name}</span>
- </div>
- );
- })}
- </div>
- </div>
- );
- }
- export function MessageExporter() {
- const steps = [
- {
- name: Locale.Export.Steps.Select,
- value: "select",
- },
- {
- name: Locale.Export.Steps.Preview,
- value: "preview",
- },
- ];
- const { currentStep, setCurrentStepIndex, currentStepIndex } =
- useSteps(steps);
- const formats = ["text", "image", "json"] as const;
- type ExportFormat = (typeof formats)[number];
- const [exportConfig, setExportConfig] = useState({
- format: "image" as ExportFormat,
- includeContext: true,
- });
- function updateExportConfig(updater: (config: typeof exportConfig) => void) {
- const config = { ...exportConfig };
- updater(config);
- setExportConfig(config);
- }
- const chatStore = useChatStore();
- const session = chatStore.currentSession();
- const { selection, updateSelection } = useMessageSelector();
- const selectedMessages = useMemo(() => {
- const ret: ChatMessage[] = [];
- if (exportConfig.includeContext) {
- ret.push(...session.mask.context);
- }
- ret.push(...session.messages.filter((m) => selection.has(m.id)));
- return ret;
- }, [
- exportConfig.includeContext,
- session.messages,
- session.mask.context,
- selection,
- ]);
- function preview() {
- if (exportConfig.format === "text") {
- return (
- <MarkdownPreviewer messages={selectedMessages} topic={session.topic} />
- );
- } else if (exportConfig.format === "json") {
- return (
- <JsonPreviewer messages={selectedMessages} topic={session.topic} />
- );
- } else {
- return (
- <ImagePreviewer messages={selectedMessages} topic={session.topic} />
- );
- }
- }
- return (
- <>
- <Steps
- steps={steps}
- index={currentStepIndex}
- onStepChange={setCurrentStepIndex}
- />
- <div
- className={styles["message-exporter-body"]}
- style={currentStep.value !== "select" ? { display: "none" } : {}}
- >
- <List>
- <ListItem
- title={Locale.Export.Format.Title}
- subTitle={Locale.Export.Format.SubTitle}
- >
- <Select
- value={exportConfig.format}
- onChange={(e) =>
- updateExportConfig(
- (config) =>
- (config.format = e.currentTarget.value as ExportFormat),
- )
- }
- >
- {formats.map((f) => (
- <option key={f} value={f}>
- {f}
- </option>
- ))}
- </Select>
- </ListItem>
- <ListItem
- title={Locale.Export.IncludeContext.Title}
- subTitle={Locale.Export.IncludeContext.SubTitle}
- >
- <input
- type="checkbox"
- checked={exportConfig.includeContext}
- onChange={(e) => {
- updateExportConfig(
- (config) => (config.includeContext = e.currentTarget.checked),
- );
- }}
- ></input>
- </ListItem>
- </List>
- <MessageSelector
- selection={selection}
- updateSelection={updateSelection}
- defaultSelectAll
- />
- </div>
- {currentStep.value === "preview" && (
- <div className={styles["message-exporter-body"]}>{preview()}</div>
- )}
- </>
- );
- }
- export function RenderExport(props: {
- messages: ChatMessage[];
- onRender: (messages: ChatMessage[]) => void;
- }) {
- const domRef = useRef<HTMLDivElement>(null);
- useEffect(() => {
- if (!domRef.current) return;
- const dom = domRef.current;
- const messages = Array.from(
- dom.getElementsByClassName(EXPORT_MESSAGE_CLASS_NAME),
- );
- if (messages.length !== props.messages.length) {
- return;
- }
- const renderMsgs = messages.map((v, i) => {
- const [role, _] = v.id.split(":");
- return {
- id: i.toString(),
- role: role as any,
- content: role === "user" ? v.textContent ?? "" : v.innerHTML,
- date: "",
- };
- });
- props.onRender(renderMsgs);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
- return (
- <div ref={domRef}>
- {props.messages.map((m, i) => (
- <div
- key={i}
- id={`${m.role}:${i}`}
- className={EXPORT_MESSAGE_CLASS_NAME}
- >
- <Markdown content={m.content} defaultShow />
- </div>
- ))}
- </div>
- );
- }
- export function PreviewActions(props: {
- download: () => void;
- copy: () => void;
- showCopy?: boolean;
- messages?: ChatMessage[];
- }) {
- const [loading, setLoading] = useState(false);
- const [shouldExport, setShouldExport] = useState(false);
- const onRenderMsgs = (msgs: ChatMessage[]) => {
- setShouldExport(false);
- api
- .share(msgs)
- .then((res) => {
- if (!res) return;
- showModal({
- title: Locale.Export.Share,
- children: [
- <input
- type="text"
- value={res}
- key="input"
- style={{
- width: "100%",
- maxWidth: "unset",
- }}
- readOnly
- onClick={(e) => e.currentTarget.select()}
- ></input>,
- ],
- actions: [
- <IconButton
- icon={<CopyIcon />}
- text={Locale.Chat.Actions.Copy}
- key="copy"
- onClick={() => copyToClipboard(res)}
- />,
- ],
- });
- setTimeout(() => {
- window.open(res, "_blank");
- }, 800);
- })
- .catch((e) => {
- console.error("[Share]", e);
- showToast(prettyObject(e));
- })
- .finally(() => setLoading(false));
- };
- const share = async () => {
- if (props.messages?.length) {
- setLoading(true);
- setShouldExport(true);
- }
- };
- return (
- <>
- <div className={styles["preview-actions"]}>
- {props.showCopy && (
- <IconButton
- text={Locale.Export.Copy}
- bordered
- shadow
- icon={<CopyIcon />}
- onClick={props.copy}
- ></IconButton>
- )}
- <IconButton
- text={Locale.Export.Download}
- bordered
- shadow
- icon={<DownloadIcon />}
- onClick={props.download}
- ></IconButton>
- {/*<IconButton*/}
- {/* text={Locale.Export.Share}*/}
- {/* bordered*/}
- {/* shadow*/}
- {/* icon={loading ? <LoadingIcon /> : <ShareIcon />}*/}
- {/* onClick={share}*/}
- {/*></IconButton>*/}
- </div>
- <div
- style={{
- position: "fixed",
- right: "200vw",
- pointerEvents: "none",
- }}
- >
- {shouldExport && (
- <RenderExport
- messages={props.messages ?? []}
- onRender={onRenderMsgs}
- />
- )}
- </div>
- </>
- );
- }
- function ExportAvatar(props: { avatar: string }) {
- if (props.avatar === DEFAULT_MASK_AVATAR) {
- return (
- <img
- src={BotIcon.src}
- width={30}
- height={30}
- alt="bot"
- className="user-avatar"
- />
- );
- }
- return <Avatar avatar={props.avatar} />;
- }
- export function ImagePreviewer(props: {
- messages: ChatMessage[];
- topic: string;
- }) {
- const chatStore = useChatStore();
- const session = chatStore.currentSession();
- const mask = session.mask;
- const config = useAppConfig();
- const previewRef = useRef<HTMLDivElement>(null);
- const copy = () => {
- showToast(Locale.Export.Image.Toast);
- const dom = previewRef.current;
- if (!dom) return;
- toBlob(dom).then((blob) => {
- if (!blob) return;
- try {
- navigator.clipboard
- .write([
- new ClipboardItem({
- "image/png": blob,
- }),
- ])
- .then(() => {
- showToast(Locale.Copy.Success);
- refreshPreview();
- });
- } catch (e) {
- console.error("[Copy Image] ", e);
- showToast(Locale.Copy.Failed);
- }
- });
- };
- const isMobile = useMobileScreen();
- const download = async () => {
- showToast(Locale.Export.Image.Toast);
- const dom = previewRef.current;
- if (!dom) return;
- const isApp = getClientConfig()?.isApp;
- try {
- const blob = await toPng(dom);
- if (!blob) return;
- if (isMobile || (isApp && window.__TAURI__)) {
- if (isApp && window.__TAURI__) {
- const result = await window.__TAURI__.dialog.save({
- defaultPath: `${props.topic}.png`,
- filters: [
- {
- name: "PNG Files",
- extensions: ["png"],
- },
- {
- name: "All Files",
- extensions: ["*"],
- },
- ],
- });
- if (result !== null) {
- const response = await fetch(blob);
- const buffer = await response.arrayBuffer();
- const uint8Array = new Uint8Array(buffer);
- await window.__TAURI__.fs.writeBinaryFile(result, uint8Array);
- showToast(Locale.Download.Success);
- } else {
- showToast(Locale.Download.Failed);
- }
- } else {
- showImageModal(blob);
- }
- } else {
- const link = document.createElement("a");
- link.download = `${props.topic}.png`;
- link.href = blob;
- link.click();
- refreshPreview();
- }
- } catch (error) {
- showToast(Locale.Download.Failed);
- }
- };
- const refreshPreview = () => {
- const dom = previewRef.current;
- if (dom) {
- dom.innerHTML = dom.innerHTML; // Refresh the content of the preview by resetting its HTML for fix a bug glitching
- }
- };
- return (
- <div className={styles["image-previewer"]}>
- <PreviewActions
- copy={copy}
- download={download}
- showCopy={!isMobile}
- messages={props.messages}
- />
- <div
- className={`${styles["preview-body"]} ${styles["default-theme"]}`}
- ref={previewRef}
- >
- <div className={styles["chat-info"]}>
- <div className={styles["logo"] + " no-dark"}>
- <NextImage
- src={ChatGptIcon.src}
- alt="logo"
- width={50}
- height={50}
- />
- </div>
- <div>
- <div className={styles["main-title"]}>ChatGPT Next Web</div>
- <div className={styles["sub-title"]}>
- github.com/Yidadaa/ChatGPT-Next-Web
- </div>
- <div className={styles["icons"]}>
- <ExportAvatar avatar={config.avatar} />
- <span className={styles["icon-space"]}>&</span>
- <ExportAvatar avatar={mask.avatar} />
- </div>
- </div>
- <div>
- <div className={styles["chat-info-item"]}>
- {Locale.Exporter.Model}: {mask.modelConfig.model}
- </div>
- <div className={styles["chat-info-item"]}>
- {Locale.Exporter.Messages}: {props.messages.length}
- </div>
- <div className={styles["chat-info-item"]}>
- {Locale.Exporter.Topic}: {session.topic}
- </div>
- <div className={styles["chat-info-item"]}>
- {Locale.Exporter.Time}:{" "}
- {new Date(
- props.messages.at(-1)?.date ?? Date.now(),
- ).toLocaleString()}
- </div>
- </div>
- </div>
- {props.messages.map((m, i) => {
- return (
- <div
- className={styles["message"] + " " + styles["message-" + m.role]}
- key={i}
- >
- <div className={styles["avatar"]}>
- <ExportAvatar
- avatar={m.role === "user" ? config.avatar : mask.avatar}
- />
- </div>
- <div className={styles["body"]}>
- <Markdown
- content={m.content}
- fontSize={config.fontSize}
- defaultShow
- />
- </div>
- </div>
- );
- })}
- </div>
- </div>
- );
- }
- export function MarkdownPreviewer(props: {
- messages: ChatMessage[];
- topic: string;
- }) {
- const mdText =
- `# ${props.topic}\n\n` +
- props.messages
- .map((m) => {
- return m.role === "user"
- ? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
- : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
- })
- .join("\n\n");
- const copy = () => {
- copyToClipboard(mdText);
- };
- const download = () => {
- downloadAs(mdText, `${props.topic}.md`);
- };
- return (
- <>
- <PreviewActions
- copy={copy}
- download={download}
- showCopy={true}
- messages={props.messages}
- />
- <div className="markdown-body">
- <pre className={styles["export-content"]}>{mdText}</pre>
- </div>
- </>
- );
- }
- export function JsonPreviewer(props: {
- messages: ChatMessage[];
- topic: string;
- }) {
- const msgs = {
- messages: [
- {
- role: "system",
- content: `${Locale.FineTuned.Sysmessage} ${props.topic}`,
- },
- ...props.messages.map((m) => ({
- role: m.role,
- content: m.content,
- })),
- ],
- };
- const mdText = "```json\n" + JSON.stringify(msgs, null, 2) + "\n```";
- const minifiedJson = JSON.stringify(msgs);
- const copy = () => {
- copyToClipboard(minifiedJson);
- };
- const download = () => {
- downloadAs(JSON.stringify(msgs), `${props.topic}.json`);
- };
- return (
- <>
- <PreviewActions
- copy={copy}
- download={download}
- showCopy={false}
- messages={props.messages}
- />
- <div className="markdown-body" onClick={copy}>
- <Markdown content={mdText} />
- </div>
- </>
- );
- }
|