diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 80c6655d..880df70d 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -7,7 +7,6 @@ import RenameIcon from "../icons/rename.svg"; import ExportIcon from "../icons/share.svg"; import ReturnIcon from "../icons/return.svg"; import CopyIcon from "../icons/copy.svg"; -import DownloadIcon from "../icons/download.svg"; import LoadingIcon from "../icons/three-dots.svg"; import PromptIcon from "../icons/prompt.svg"; import MaskIcon from "../icons/mask.svg"; @@ -53,7 +52,7 @@ import { IconButton } from "./button"; import styles from "./home.module.scss"; import chatStyle from "./chat.module.scss"; -import { ListItem, Modal, showModal, showToast } from "./ui-lib"; +import { ListItem, Modal } from "./ui-lib"; import { useLocation, useNavigate } from "react-router-dom"; import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant"; import { Avatar } from "./emoji"; @@ -61,49 +60,12 @@ import { MaskAvatar, MaskConfig } from "./mask"; import { useMaskStore } from "../store/mask"; import { useCommand } from "../command"; import { prettyObject } from "../utils/format"; +import { ExportMessageModal } from "./exporter"; const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { loading: () => , }); -function exportMessages(messages: ChatMessage[], topic: string) { - const mdText = - `# ${topic}\n\n` + - messages - .map((m) => { - return m.role === "user" - ? `## ${Locale.Export.MessageFromYou}:\n${m.content}` - : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`; - }) - .join("\n\n"); - const filename = `${topic}.md`; - - showModal({ - title: Locale.Export.Title, - children: ( -
-
{mdText}
-
- ), - actions: [ - } - bordered - text={Locale.Export.Copy} - onClick={() => copyToClipboard(mdText)} - />, - } - bordered - text={Locale.Export.Download} - onClick={() => downloadAs(mdText, filename)} - />, - ], - }); -} - export function SessionConfigModel(props: { onClose: () => void }) { const chatStore = useChatStore(); const session = chatStore.currentSession(); @@ -451,6 +413,8 @@ export function Chat() { const config = useAppConfig(); const fontSize = config.fontSize; + const [showExport, setShowExport] = useState(false); + const inputRef = useRef(null); const [userInput, setUserInput] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -739,10 +703,7 @@ export function Chat() { bordered title={Locale.Chat.Actions.Export} onClick={() => { - exportMessages( - session.messages.filter((msg) => !msg.isError), - session.topic, - ); + setShowExport(true); }} /> @@ -917,6 +878,10 @@ export function Chat() { /> + + {showExport && ( + setShowExport(false)} /> + )} ); } diff --git a/app/components/exporter.module.scss b/app/components/exporter.module.scss new file mode 100644 index 00000000..1460ac76 --- /dev/null +++ b/app/components/exporter.module.scss @@ -0,0 +1,212 @@ +.message-exporter { + &-body { + margin-top: 20px; + } +} + +.export-content { + white-space: break-spaces; + padding: 10px !important; +} + +.steps { + background-color: var(--gray); + border-radius: 10px; + overflow: hidden; + padding: 5px; + position: relative; + box-shadow: var(--card-shadow) inset; + + .steps-progress { + $padding: 5px; + height: calc(100% - 2 * $padding); + width: calc(100% - 2 * $padding); + position: absolute; + top: $padding; + left: $padding; + + &-inner { + box-sizing: border-box; + box-shadow: var(--card-shadow); + border: var(--border-in-light); + content: ""; + display: inline-block; + width: 0%; + height: 100%; + background-color: var(--white); + transition: all ease 0.3s; + border-radius: 8px; + } + } + + .steps-inner { + display: flex; + transform: scale(1); + + .step { + flex-grow: 1; + padding: 5px 10px; + font-size: 14px; + color: var(--black); + opacity: 0.5; + transition: all ease 0.3s; + + display: flex; + align-items: center; + justify-content: center; + + $radius: 8px; + + &-finished { + opacity: 0.9; + } + + &:hover { + opacity: 0.8; + } + + &-current { + color: var(--primary); + } + + .step-index { + background-color: var(--gray); + border: var(--border-in-light); + border-radius: 6px; + display: inline-block; + padding: 0px 5px; + font-size: 12px; + margin-right: 8px; + opacity: 0.8; + } + + .step-name { + font-size: 12px; + } + } + } +} + +.preview-actions { + margin-bottom: 20px; + display: flex; + justify-content: space-between; + + button { + flex-grow: 1; + &:not(:last-child) { + margin-right: 10px; + } + } +} + +.image-previewer { + .preview-body { + border-radius: 10px; + padding: 20px; + box-shadow: var(--card-shadow) inset; + background-color: var(--gray); + + .chat-info { + background-color: var(--second); + padding: 20px; + border-radius: 10px; + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: flex-end; + position: relative; + overflow: hidden; + + @media screen and (max-width: 600px) { + flex-direction: column; + align-items: flex-start; + + .icons { + margin-bottom: 20px; + } + } + + .logo { + position: absolute; + top: 0px; + left: 0px; + transform: scale(2); + } + + .main-title { + font-size: 20px; + font-weight: bolder; + } + + .sub-title { + font-size: 12px; + } + + .icons { + margin-top: 10px; + display: flex; + align-items: center; + + .icon-space { + font-size: 12px; + margin: 0 10px; + font-weight: bolder; + color: var(--primary); + } + } + + .chat-info-item { + font-size: 12px; + color: var(--primary); + padding: 2px 15px; + border-radius: 10px; + background-color: var(--white); + box-shadow: var(--card-shadow); + + &:not(:last-child) { + margin-bottom: 5px; + } + } + } + + .message { + margin-bottom: 20px; + display: flex; + + .avatar { + margin-right: 10px; + } + + .body { + border-radius: 10px; + padding: 8px 10px; + max-width: calc(100% - 104px); + box-shadow: var(--card-shadow); + border: var(--border-in-light); + } + + &-assistant { + .body { + background-color: var(--white); + } + } + + &-user { + flex-direction: row-reverse; + + .avatar { + margin-right: 0; + } + + .body { + background-color: var(--second); + margin-right: 10px; + } + } + } + } + + .default-theme { + } +} diff --git a/app/components/exporter.tsx b/app/components/exporter.tsx new file mode 100644 index 00000000..8f38be0f --- /dev/null +++ b/app/components/exporter.tsx @@ -0,0 +1,398 @@ +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: () => , +}); + +export function ExportMessageModal(props: { onClose: () => void }) { + return ( +
+ +
+ +
+
+
+ ); +} + +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 ( +
+
+
+
+
+ {steps.map((step, i) => { + return ( +
{ + props.onStepChange?.(i); + }} + role="button" + > + {i + 1} + {step.name} +
+ ); + })} +
+
+ ); +} + +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 ( + <> + + +
+ {currentStep.value === "select" && ( + <> + + + + + + { + updateExportConfig( + (config) => + (config.includeContext = e.currentTarget.checked), + ); + }} + > + + + + + )} + + {currentStep.value === "preview" && ( + <> + {exportConfig.format === "text" ? ( + + ) : ( + + )} + + )} +
+ + ); +} + +export function PreviewActions(props: { + download: () => void; + copy: () => void; +}) { + return ( +
+ } + onClick={props.copy} + > + } + onClick={props.download} + > + } + onClick={() => showToast(Locale.WIP)} + > +
+ ); +} + +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(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 ( +
+ +
+
+
+ +
+ +
+
ChatGPT Next Web
+
+ github.com/Yidadaa/ChatGPT-Next-Web +
+
+ + & + +
+
+
+
+ Model: {mask.modelConfig.model} +
+
+ Messages: {props.messages.length} +
+
+ Topic: {session.topic} +
+
+ Time:{" "} + {new Date( + props.messages.at(-1)?.date ?? Date.now(), + ).toLocaleString()} +
+
+
+ {props.messages.map((m, i) => { + return ( +
+
+ {m.role === "user" ? ( + + ) : ( + + )} +
+ +
+ +
+
+ ); + })} +
+
+ ); +} + +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 ( + <> + +
+
{mdText}
+
+ + ); +} diff --git a/app/components/home.module.scss b/app/components/home.module.scss index e0a1c3d0..96ce17c1 100644 --- a/app/components/home.module.scss +++ b/app/components/home.module.scss @@ -558,11 +558,6 @@ } } -.export-content { - white-space: break-spaces; - padding: 10px !important; -} - .loading-content { display: flex; flex-direction: column; diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index fb37fdc4..108b0570 100644 --- a/app/components/markdown.tsx +++ b/app/components/markdown.tsx @@ -121,7 +121,7 @@ export function Markdown( content: string; loading?: boolean; fontSize?: number; - parentRef: RefObject; + parentRef?: RefObject; defaultShow?: boolean; } & React.DOMAttributes, ) { @@ -129,7 +129,7 @@ export function Markdown( const renderedHeight = useRef(0); const inView = useRef(!!props.defaultShow); - const parent = props.parentRef.current; + const parent = props.parentRef?.current; const md = mdRef.current; const checkInView = () => { diff --git a/app/components/message-selector.module.scss b/app/components/message-selector.module.scss new file mode 100644 index 00000000..c1bbf201 --- /dev/null +++ b/app/components/message-selector.module.scss @@ -0,0 +1,55 @@ +.message-selector { + .message-filter { + display: flex; + + .search-bar { + max-width: unset; + flex-grow: 1; + } + + .filter-item:not(:last-child) { + margin-right: 10px; + } + } + + .messages { + margin-top: 20px; + border-radius: 10px; + border: var(--border-in-light); + overflow: hidden; + + .message { + display: flex; + align-items: center; + padding: 8px 10px; + cursor: pointer; + + &-selected { + background-color: var(--second); + } + + &:not(:last-child) { + border-bottom: var(--border-in-light); + } + + .avatar { + margin-right: 10px; + } + + .body { + flex-grow: 1; + max-width: calc(100% - 40px); + + .date { + font-size: 12px; + line-height: 1.2; + opacity: 0.5; + } + + .content { + font-size: 12px; + } + } + } + } +} diff --git a/app/components/message-selector.tsx b/app/components/message-selector.tsx new file mode 100644 index 00000000..2c100082 --- /dev/null +++ b/app/components/message-selector.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from "react"; +import { ChatMessage, useAppConfig, useChatStore } from "../store"; +import { Updater } from "../typing"; +import { IconButton } from "./button"; +import { Avatar } from "./emoji"; +import { MaskAvatar } from "./mask"; +import Locale from "../locales"; + +import styles from "./message-selector.module.scss"; + +function useShiftRange() { + const [startIndex, setStartIndex] = useState(); + const [endIndex, setEndIndex] = useState(); + const [shiftDown, setShiftDown] = useState(false); + + const onClickIndex = (index: number) => { + if (shiftDown && startIndex !== undefined) { + setEndIndex(index); + } else { + setStartIndex(index); + setEndIndex(undefined); + } + }; + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Shift") return; + setShiftDown(true); + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.key !== "Shift") return; + setShiftDown(false); + setStartIndex(undefined); + setEndIndex(undefined); + }; + + window.addEventListener("keyup", onKeyUp); + window.addEventListener("keydown", onKeyDown); + + return () => { + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("keydown", onKeyDown); + }; + }, []); + + return { + onClickIndex, + startIndex, + endIndex, + }; +} + +export function useMessageSelector() { + const [selection, setSelection] = useState(new Set()); + const updateSelection: Updater> = (updater) => { + const newSelection = new Set(selection); + updater(newSelection); + setSelection(newSelection); + }; + + return { + selection, + updateSelection, + }; +} + +export function MessageSelector(props: { + selection: Set; + updateSelection: Updater>; + defaultSelectAll?: boolean; + onSelected?: (messages: ChatMessage[]) => void; +}) { + const chatStore = useChatStore(); + const session = chatStore.currentSession(); + const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming; + const messages = session.messages.filter( + (m, i) => + m.id && // messsage must has id + isValid(m) && + (i >= session.messages.length - 1 || isValid(session.messages[i + 1])), + ); + const messageCount = messages.length; + const config = useAppConfig(); + + const [searchInput, setSearchInput] = useState(""); + const [searchIds, setSearchIds] = useState(new Set()); + const isInSearchResult = (id: number) => { + return searchInput.length === 0 || searchIds.has(id); + }; + const doSearch = (text: string) => { + const searchResuts = new Set(); + if (text.length > 0) { + messages.forEach((m) => + m.content.includes(text) ? searchResuts.add(m.id!) : null, + ); + } + setSearchIds(searchResuts); + }; + + // for range selection + const { startIndex, endIndex, onClickIndex } = useShiftRange(); + + const selectAll = () => { + props.updateSelection((selection) => + messages.forEach((m) => selection.add(m.id!)), + ); + }; + + useEffect(() => { + if (props.defaultSelectAll) { + selectAll(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (startIndex === undefined || endIndex === undefined) { + return; + } + const [start, end] = [startIndex, endIndex].sort((a, b) => a - b); + props.updateSelection((selection) => { + for (let i = start; i <= end; i += 1) { + selection.add(messages[i].id ?? i); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [startIndex, endIndex]); + + return ( +
+
+ { + setSearchInput(e.currentTarget.value); + doSearch(e.currentTarget.value); + }} + > + + + + props.updateSelection((selection) => { + selection.clear(); + messages + .slice(messageCount - 10) + .forEach((m) => selection.add(m.id!)); + }) + } + /> + + props.updateSelection((selection) => selection.clear()) + } + /> +
+ +
+ {messages.map((m, i) => { + if (!isInSearchResult(m.id!)) return null; + + return ( +
{ + props.updateSelection((selection) => { + const id = m.id ?? i; + selection.has(id) ? selection.delete(id) : selection.add(id); + }); + onClickIndex(i); + }} + > +
+ {m.role === "user" ? ( + + ) : ( + + )} +
+
+
+ {new Date(m.date).toLocaleString()} +
+
+ {m.content} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/app/locales/cn.ts b/app/locales/cn.ts index c164603b..989a54bf 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -36,11 +36,30 @@ const cn = { }, }, Export: { - Title: "导出聊天记录为 Markdown", + Title: "分享聊天记录", Copy: "全部复制", Download: "下载文件", + Share: "分享到 ShareGPT", MessageFromYou: "来自你的消息", MessageFromChatGPT: "来自 ChatGPT 的消息", + Format: { + Title: "导出格式", + SubTitle: "可以导出 Markdown 文本或者 PNG 图片", + }, + IncludeContext: { + Title: "包含面具上下文", + SubTitle: "是否在消息中展示面具上下文", + }, + Steps: { + Select: "选取", + Preview: "预览", + }, + }, + Select: { + Search: "搜索消息", + All: "选取全部", + Latest: "最近十条", + Clear: "清除选中", }, Memory: { Title: "历史摘要", diff --git a/app/locales/en.ts b/app/locales/en.ts index 12ad101a..1ff66558 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -37,11 +37,30 @@ const en: RequiredLocaleType = { }, }, Export: { - Title: "All Messages", + Title: "Export Messages", Copy: "Copy All", Download: "Download", MessageFromYou: "Message From You", MessageFromChatGPT: "Message From ChatGPT", + Share: "Share to ShareGPT", + Format: { + Title: "Export Format", + SubTitle: "Markdown or PNG Image", + }, + IncludeContext: { + Title: "Including Context", + SubTitle: "Export context prompts in mask or not", + }, + Steps: { + Select: "Select", + Preview: "Preview", + }, + }, + Select: { + Search: "Search", + All: "Select All", + Latest: "Select Latest", + Clear: "Clear", }, Memory: { Title: "Memory Prompt", diff --git a/package.json b/package.json index 7acc6aad..e1d97f7b 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,13 @@ "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev" }, "dependencies": { - "@hello-pangea/dnd": "^16.2.0", "@fortaine/fetch-event-source": "^3.0.6", + "@hello-pangea/dnd": "^16.2.0", "@svgr/webpack": "^6.5.1", "@vercel/analytics": "^0.1.11", "emoji-picker-react": "^4.4.7", "fuse.js": "^6.6.2", + "html-to-image": "^1.11.11", "mermaid": "^10.1.0", "next": "^13.4.3", "node-fetch": "^3.3.1", diff --git a/yarn.lock b/yarn.lock index 584db6dc..1e7b44ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3215,6 +3215,11 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: dependencies: react-is "^16.7.0" +html-to-image@^1.11.11: + version "1.11.11" + resolved "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.11.tgz#c0f8a34dc9e4b97b93ff7ea286eb8562642ebbea" + integrity sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA== + human-signals@^4.3.0: version "4.3.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2"