import { ChatMessage, useAppConfig, useChatStore } from "../store"; import Locale from "../locales"; import styles from "./exporter.module.scss"; import { List, ListItem, Modal, showToast } from "./ui-lib"; import { IconButton } from "./button"; import { copyToClipboard, downloadAs } from "../utils"; import CopyIcon from "../icons/copy.svg"; import LoadingIcon from "../icons/three-dots.svg"; import ChatGptIcon from "../icons/chatgpt.svg"; import ShareIcon from "../icons/share.svg"; import DownloadIcon from "../icons/download.svg"; import { useMemo, useRef, useState } from "react"; import { MessageSelector, useMessageSelector } from "./message-selector"; import { Avatar } from "./emoji"; import { MaskAvatar } from "./mask"; import dynamic from "next/dynamic"; import { toBlob, toPng } from "html-to-image"; 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}> <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"] 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, i) => selection.has(m.id ?? i))); return ret; }, [ exportConfig.includeContext, session.messages, session.mask.context, selection, ]); return ( <> <Steps steps={steps} index={currentStepIndex} onStepChange={setCurrentStepIndex} /> <div className={styles["message-exporter-body"]}> {currentStep.value === "select" && ( <> <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 /> </> )} {currentStep.value === "preview" && ( <> {exportConfig.format === "text" ? ( <MarkdownPreviewer messages={selectedMessages} topic={session.topic} /> ) : ( <ImagePreviewer messages={selectedMessages} topic={session.topic} /> )} </> )} </div> </> ); } export function PreviewActions(props: { download: () => void; copy: () => void; }) { return ( <div className={styles["preview-actions"]}> <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={<ShareIcon />} onClick={() => showToast(Locale.WIP)} ></IconButton> </div> ); } 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 = () => { 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); }); } catch (e) { console.error("[Copy Image] ", e); showToast(Locale.Copy.Failed); } }); }; const download = () => { const dom = previewRef.current; if (!dom) return; toPng(dom) .then((blob) => { if (!blob) return; const link = document.createElement("a"); link.download = `${props.topic}.png`; link.href = blob; link.click(); }) .catch((e) => console.log("[Export Image] ", e)); }; return ( <div className={styles["image-previewer"]}> <PreviewActions copy={copy} download={download} /> <div className={`${styles["preview-body"]} ${styles["default-theme"]}`} ref={previewRef} > <div className={styles["chat-info"]}> <div className={styles["logo"] + " no-dark"}> <ChatGptIcon /> </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"]}> <Avatar avatar={config.avatar}></Avatar> <span className={styles["icon-space"]}>&</span> <MaskAvatar mask={session.mask} /> </div> </div> <div> <div className={styles["chat-info-item"]}> Model: {mask.modelConfig.model} </div> <div className={styles["chat-info-item"]}> Messages: {props.messages.length} </div> <div className={styles["chat-info-item"]}> Topic: {session.topic} </div> <div className={styles["chat-info-item"]}> 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"]}> {m.role === "user" ? ( <Avatar avatar={config.avatar}></Avatar> ) : ( <MaskAvatar mask={session.mask} /> )} </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} /> <div className="markdown-body"> <pre className={styles["export-content"]}>{mdText}</pre> </div> </> ); }