Merge remote-tracking branch 'upstream/main'

This commit is contained in:
ZhaoLiu
2023-05-31 15:32:52 +08:00
47 changed files with 369 additions and 447 deletions

View File

@@ -131,7 +131,8 @@
position: absolute;
top: 0px;
left: 0px;
transform: scale(2);
height: 50%;
transform: scale(1.5);
}
.main-title {
@@ -184,6 +185,10 @@
max-width: calc(100% - 104px);
box-shadow: var(--card-shadow);
border: var(--border-in-light);
* {
overflow: hidden;
}
}
&-assistant {

View File

@@ -1,23 +1,28 @@
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 { List, ListItem, Modal, Select, 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.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 { useMemo, useRef, useState } from "react";
import { useEffect, 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 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 />,
@@ -150,109 +155,208 @@ export function MessageExporter() {
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
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"]}>
{exportConfig.format === "text" ? (
<MarkdownPreviewer
messages={selectedMessages}
topic={session.topic}
/>
) : (
<ImagePreviewer messages={selectedMessages} topic={session.topic} />
)}
</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) => {
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>
</>
);
}
function ExportAvatar(props: { avatar: string }) {
if (props.avatar === DEFAULT_MASK_AVATAR) {
return (
<NextImage
src={BotIcon.src}
width={30}
height={30}
alt="bot"
className="user-avatar"
/>
);
}
return <Avatar avatar={props.avatar}></Avatar>;
}
export function ImagePreviewer(props: {
messages: ChatMessage[];
topic: string;
@@ -312,14 +416,24 @@ 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}
>
<div className={styles["chat-info"]}>
<div className={styles["logo"] + " no-dark"}>
<ChatGptIcon />
<NextImage
src={ChatGptIcon.src}
alt="logo"
width={50}
height={50}
/>
</div>
<div>
@@ -328,9 +442,9 @@ export function ImagePreviewer(props: {
github.com/Yidadaa/ChatGPT-Next-Web
</div>
<div className={styles["icons"]}>
<Avatar avatar={config.avatar}></Avatar>
<ExportAvatar avatar={config.avatar} />
<span className={styles["icon-space"]}>&</span>
<MaskAvatar mask={session.mask} />
<ExportAvatar avatar={mask.avatar} />
</div>
</div>
<div>
@@ -358,14 +472,12 @@ export function ImagePreviewer(props: {
key={i}
>
<div className={styles["avatar"]}>
{m.role === "user" ? (
<Avatar avatar={config.avatar}></Avatar>
) : (
<MaskAvatar mask={session.mask} />
)}
<ExportAvatar
avatar={m.role === "user" ? config.avatar : mask.avatar}
/>
</div>
<div className={`${styles["body"]} `}>
<div className={styles["body"]}>
<Markdown
content={m.content}
fontSize={config.fontSize}
@@ -403,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>

View File

@@ -141,7 +141,7 @@
.sidebar-sub-title {
font-size: 12px;
font-weight: 400px;
font-weight: 400;
animation: slide-in ease 0.3s;
}

View File

@@ -4,4 +4,9 @@
padding: 5px 15px 5px 10px;
font-size: 12px;
display: flex;
max-width: 40%;
input[type="range"] {
max-width: calc(100% - 50px);
}
}

View File

@@ -75,7 +75,7 @@ export function MessageSelector(props: {
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
const messages = session.messages.filter(
(m, i) =>
m.id && // messsage must has id
m.id && // message must have id
isValid(m) &&
(i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
);
@@ -88,13 +88,13 @@ export function MessageSelector(props: {
return searchInput.length === 0 || searchIds.has(id);
};
const doSearch = (text: string) => {
const searchResuts = new Set<number>();
const searchResults = new Set<number>();
if (text.length > 0) {
messages.forEach((m) =>
m.content.includes(text) ? searchResuts.add(m.id!) : null,
m.content.includes(text) ? searchResults.add(m.id!) : null,
);
}
setSearchIds(searchResuts);
setSearchIds(searchResults);
};
// for range selection
@@ -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!));
})
}

View File

@@ -42,7 +42,7 @@ export function ListItem(props: {
className?: string;
}) {
return (
<div className={styles["list-item"] + ` ${props.className}`}>
<div className={styles["list-item"] + ` ${props.className || ""}`}>
<div className={styles["list-header"]}>
{props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
<div className={styles["list-item-title"]}>