You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
	
	
		
			704 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
		
		
			
		
	
	
			704 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
| 
											9 months ago
										 | /* eslint-disable @next/next/no-img-element */ | ||
|  | import { ChatMessage, 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, | ||
|  |   getMessageImages, | ||
|  |   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 { prettyObject } from "../utils/format"; | ||
|  | import { EXPORT_MESSAGE_CLASS_NAME } from "../constant"; | ||
|  | import { getClientConfig } from "../config/client"; | ||
|  | import { type ClientApi, getClientApi } from "../client/api"; | ||
|  | import { getMessageTextContent } from "../utils"; | ||
|  | import clsx from "clsx"; | ||
|  | 
 | ||
|  | 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={clsx("clickable", styles["step"], { | ||
|  |                 [styles["step-finished"]]: i <= props.index, | ||
|  |                 [styles["step-current"]]: i === props.index, | ||
|  |               })} | ||
|  |               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={getMessageTextContent(m)} 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 config = useAppConfig(); | ||
|  |   const onRenderMsgs = (msgs: ChatMessage[]) => { | ||
|  |     setShouldExport(false); | ||
|  | 
 | ||
|  |     const api: ClientApi = getClientApi(config.modelConfig.providerName); | ||
|  | 
 | ||
|  |     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={clsx(styles["preview-body"], styles["default-theme"])} | ||
|  |         ref={previewRef} | ||
|  |       > | ||
|  |         <div className={styles["chat-info"]}> | ||
|  |           <div className={clsx(styles["logo"], "no-dark")}> | ||
|  |             <NextImage | ||
|  |               src={ChatGptIcon.src} | ||
|  |               alt="logo" | ||
|  |               width={50} | ||
|  |               height={50} | ||
|  |             /> | ||
|  |           </div> | ||
|  | 
 | ||
|  |           <div> | ||
|  |             <div className={styles["main-title"]}>NextChat</div> | ||
|  |             <div className={styles["sub-title"]}> | ||
|  |               github.com/ChatGPTNextWeb/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={clsx(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={getMessageTextContent(m)} | ||
|  |                   fontSize={config.fontSize} | ||
|  |                   fontFamily={config.fontFamily} | ||
|  |                   defaultShow | ||
|  |                 /> | ||
|  |                 {getMessageImages(m).length == 1 && ( | ||
|  |                   <img | ||
|  |                     key={i} | ||
|  |                     src={getMessageImages(m)[0]} | ||
|  |                     alt="message" | ||
|  |                     className={styles["message-image"]} | ||
|  |                   /> | ||
|  |                 )} | ||
|  |                 {getMessageImages(m).length > 1 && ( | ||
|  |                   <div | ||
|  |                     className={styles["message-images"]} | ||
|  |                     style={ | ||
|  |                       { | ||
|  |                         "--image-count": getMessageImages(m).length, | ||
|  |                       } as React.CSSProperties | ||
|  |                     } | ||
|  |                   > | ||
|  |                     {getMessageImages(m).map((src, i) => ( | ||
|  |                       <img | ||
|  |                         key={i} | ||
|  |                         src={src} | ||
|  |                         alt="message" | ||
|  |                         className={styles["message-image-multi"]} | ||
|  |                       /> | ||
|  |                     ))} | ||
|  |                   </div> | ||
|  |                 )} | ||
|  |               </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${getMessageTextContent(m)}` | ||
|  |           : `## ${Locale.Export.MessageFromChatGPT}:\n${getMessageTextContent( | ||
|  |               m, | ||
|  |             ).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> | ||
|  |     </> | ||
|  |   ); | ||
|  | } |