diff --git a/app/client/api.ts b/app/client/api.ts index a966d733..fb829f97 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -1,5 +1,5 @@ import { ACCESS_CODE_PREFIX } from "../constant"; -import { ModelConfig, ModelType, useAccessStore } from "../store"; +import { ChatMessage, ModelConfig, ModelType, useAccessStore } from "../store"; import { ChatGPTApi } from "./platforms/openai"; export const ROLES = ["system", "user", "assistant"] as const; @@ -54,6 +54,41 @@ export class ClientApi { prompts() {} masks() {} + + async share(messages: ChatMessage[], avatarUrl: string | null = null) { + const msgs = messages + .map((m) => ({ + from: m.role === "user" ? "human" : "gpt", + value: m.content, + })) + .concat([ + { + from: "human", + value: + "Share from [ChatGPT Next Web]: https://github.com/Yidadaa/ChatGPT-Next-Web", + }, + ]); + // 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用 + // Please do not modify this message + + console.log("[Share]", msgs); + const res = await fetch("/sharegpt", { + body: JSON.stringify({ + avatarUrl, + items: msgs, + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + const resJson = await res.json(); + console.log("[Share]", resJson); + if (resJson.id) { + return `https://shareg.pt/${resJson.id}`; + } + } } export const api = new ClientApi(); diff --git a/app/components/exporter.tsx b/app/components/exporter.tsx index a9a1071d..10d5af99 100644 --- a/app/components/exporter.tsx +++ b/app/components/exporter.tsx @@ -12,14 +12,17 @@ import ShareIcon from "../icons/share.svg"; import BotIcon from "../icons/bot.png"; import DownloadIcon from "../icons/download.svg"; -import { useMemo, useRef, useState } from "react"; +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 { toBlob, toJpeg, 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"; const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { loading: () => <LoadingIcon />, @@ -214,37 +217,127 @@ export function MessageExporter() { ); } +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) => { + const [_, role] = v.id.split(":"); + return { + role: role as any, + content: v.innerHTML, + date: "", + }; + }); + + props.onRender(renderMsgs); + }); + + 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; + 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 && ( + <> + <div className={styles["preview-actions"]}> + {props.showCopy && ( + <IconButton + text={Locale.Export.Copy} + bordered + shadow + icon={<CopyIcon />} + onClick={props.copy} + ></IconButton> + )} <IconButton - text={Locale.Export.Copy} + text={Locale.Export.Download} bordered shadow - icon={<CopyIcon />} - onClick={props.copy} + icon={<DownloadIcon />} + onClick={props.download} ></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> + <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> + </> ); } @@ -323,7 +416,12 @@ export function ImagePreviewer(props: { return ( <div className={styles["image-previewer"]}> - <PreviewActions copy={copy} download={download} showCopy={!isMobile} /> + <PreviewActions + copy={copy} + download={download} + showCopy={!isMobile} + messages={props.messages} + /> <div className={`${styles["preview-body"]} ${styles["default-theme"]}`} ref={previewRef} @@ -417,7 +515,11 @@ export function MarkdownPreviewer(props: { return ( <> - <PreviewActions copy={copy} download={download} /> + <PreviewActions + copy={copy} + download={download} + messages={props.messages} + /> <div className="markdown-body"> <pre className={styles["export-content"]}>{mdText}</pre> </div> diff --git a/app/components/message-selector.tsx b/app/components/message-selector.tsx index 837591ac..300d4537 100644 --- a/app/components/message-selector.tsx +++ b/app/components/message-selector.tsx @@ -126,6 +126,8 @@ export function MessageSelector(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [startIndex, endIndex]); + const LATEST_COUNT = 4; + return ( <div className={styles["message-selector"]}> <div className={styles["message-filter"]}> @@ -155,7 +157,7 @@ export function MessageSelector(props: { props.updateSelection((selection) => { selection.clear(); messages - .slice(messageCount - 10) + .slice(messageCount - LATEST_COUNT) .forEach((m) => selection.add(m.id!)); }) } diff --git a/app/constant.ts b/app/constant.ts index 577c0af6..0fb18c2f 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -42,3 +42,5 @@ export const ACCESS_CODE_PREFIX = "ak-"; export const LAST_INPUT_KEY = "last-input"; export const REQUEST_TIMEOUT_MS = 60000; + +export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown"; diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 989a54bf..48134e38 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -58,7 +58,7 @@ const cn = { Select: { Search: "搜索消息", All: "选取全部", - Latest: "最近十条", + Latest: "最近几条", Clear: "清除选中", }, Memory: { diff --git a/next.config.mjs b/next.config.mjs index 9c0ce9fa..34c058b7 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,6 +11,10 @@ const nextConfig = { source: "/google-fonts/:path*", destination: "https://fonts.googleapis.com/:path*", }, + { + source: "/sharegpt", + destination: "https://sharegpt.com/api/conversations", + }, ]; const apiUrl = process.env.API_URL;