forked from XiaoMo/ChatGPT-Next-Web
feat: close #580 export messages as image
This commit is contained in:
parent
05b1b8b240
commit
4dad7f2ab6
@ -7,7 +7,6 @@ import RenameIcon from "../icons/rename.svg";
|
|||||||
import ExportIcon from "../icons/share.svg";
|
import ExportIcon from "../icons/share.svg";
|
||||||
import ReturnIcon from "../icons/return.svg";
|
import ReturnIcon from "../icons/return.svg";
|
||||||
import CopyIcon from "../icons/copy.svg";
|
import CopyIcon from "../icons/copy.svg";
|
||||||
import DownloadIcon from "../icons/download.svg";
|
|
||||||
import LoadingIcon from "../icons/three-dots.svg";
|
import LoadingIcon from "../icons/three-dots.svg";
|
||||||
import PromptIcon from "../icons/prompt.svg";
|
import PromptIcon from "../icons/prompt.svg";
|
||||||
import MaskIcon from "../icons/mask.svg";
|
import MaskIcon from "../icons/mask.svg";
|
||||||
@ -53,7 +52,7 @@ import { IconButton } from "./button";
|
|||||||
import styles from "./home.module.scss";
|
import styles from "./home.module.scss";
|
||||||
import chatStyle from "./chat.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 { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
|
import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
|
||||||
import { Avatar } from "./emoji";
|
import { Avatar } from "./emoji";
|
||||||
@ -61,49 +60,12 @@ import { MaskAvatar, MaskConfig } from "./mask";
|
|||||||
import { useMaskStore } from "../store/mask";
|
import { useMaskStore } from "../store/mask";
|
||||||
import { useCommand } from "../command";
|
import { useCommand } from "../command";
|
||||||
import { prettyObject } from "../utils/format";
|
import { prettyObject } from "../utils/format";
|
||||||
|
import { ExportMessageModal } from "./exporter";
|
||||||
|
|
||||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||||
loading: () => <LoadingIcon />,
|
loading: () => <LoadingIcon />,
|
||||||
});
|
});
|
||||||
|
|
||||||
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: (
|
|
||||||
<div className="markdown-body">
|
|
||||||
<pre className={styles["export-content"]}>{mdText}</pre>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
<IconButton
|
|
||||||
key="copy"
|
|
||||||
icon={<CopyIcon />}
|
|
||||||
bordered
|
|
||||||
text={Locale.Export.Copy}
|
|
||||||
onClick={() => copyToClipboard(mdText)}
|
|
||||||
/>,
|
|
||||||
<IconButton
|
|
||||||
key="download"
|
|
||||||
icon={<DownloadIcon />}
|
|
||||||
bordered
|
|
||||||
text={Locale.Export.Download}
|
|
||||||
onClick={() => downloadAs(mdText, filename)}
|
|
||||||
/>,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionConfigModel(props: { onClose: () => void }) {
|
export function SessionConfigModel(props: { onClose: () => void }) {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const session = chatStore.currentSession();
|
const session = chatStore.currentSession();
|
||||||
@ -451,6 +413,8 @@ export function Chat() {
|
|||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const fontSize = config.fontSize;
|
const fontSize = config.fontSize;
|
||||||
|
|
||||||
|
const [showExport, setShowExport] = useState(false);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -739,10 +703,7 @@ export function Chat() {
|
|||||||
bordered
|
bordered
|
||||||
title={Locale.Chat.Actions.Export}
|
title={Locale.Chat.Actions.Export}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
exportMessages(
|
setShowExport(true);
|
||||||
session.messages.filter((msg) => !msg.isError),
|
|
||||||
session.topic,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -917,6 +878,10 @@ export function Chat() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showExport && (
|
||||||
|
<ExportMessageModal onClose={() => setShowExport(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
212
app/components/exporter.module.scss
Normal file
212
app/components/exporter.module.scss
Normal file
@ -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 {
|
||||||
|
}
|
||||||
|
}
|
398
app/components/exporter.tsx
Normal file
398
app/components/exporter.tsx
Normal file
@ -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: () => <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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -558,11 +558,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-content {
|
|
||||||
white-space: break-spaces;
|
|
||||||
padding: 10px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-content {
|
.loading-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -121,7 +121,7 @@ export function Markdown(
|
|||||||
content: string;
|
content: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
parentRef: RefObject<HTMLDivElement>;
|
parentRef?: RefObject<HTMLDivElement>;
|
||||||
defaultShow?: boolean;
|
defaultShow?: boolean;
|
||||||
} & React.DOMAttributes<HTMLDivElement>,
|
} & React.DOMAttributes<HTMLDivElement>,
|
||||||
) {
|
) {
|
||||||
@ -129,7 +129,7 @@ export function Markdown(
|
|||||||
const renderedHeight = useRef(0);
|
const renderedHeight = useRef(0);
|
||||||
const inView = useRef(!!props.defaultShow);
|
const inView = useRef(!!props.defaultShow);
|
||||||
|
|
||||||
const parent = props.parentRef.current;
|
const parent = props.parentRef?.current;
|
||||||
const md = mdRef.current;
|
const md = mdRef.current;
|
||||||
|
|
||||||
const checkInView = () => {
|
const checkInView = () => {
|
||||||
|
55
app/components/message-selector.module.scss
Normal file
55
app/components/message-selector.module.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
211
app/components/message-selector.tsx
Normal file
211
app/components/message-selector.tsx
Normal file
@ -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<number>();
|
||||||
|
const [endIndex, setEndIndex] = useState<number>();
|
||||||
|
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<number>());
|
||||||
|
const updateSelection: Updater<Set<number>> = (updater) => {
|
||||||
|
const newSelection = new Set<number>(selection);
|
||||||
|
updater(newSelection);
|
||||||
|
setSelection(newSelection);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
selection,
|
||||||
|
updateSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageSelector(props: {
|
||||||
|
selection: Set<number>;
|
||||||
|
updateSelection: Updater<Set<number>>;
|
||||||
|
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<number>());
|
||||||
|
const isInSearchResult = (id: number) => {
|
||||||
|
return searchInput.length === 0 || searchIds.has(id);
|
||||||
|
};
|
||||||
|
const doSearch = (text: string) => {
|
||||||
|
const searchResuts = new Set<number>();
|
||||||
|
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 (
|
||||||
|
<div className={styles["message-selector"]}>
|
||||||
|
<div className={styles["message-filter"]}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={Locale.Select.Search}
|
||||||
|
className={styles["filter-item"] + " " + styles["search-bar"]}
|
||||||
|
value={searchInput}
|
||||||
|
onInput={(e) => {
|
||||||
|
setSearchInput(e.currentTarget.value);
|
||||||
|
doSearch(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
></input>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
text={Locale.Select.All}
|
||||||
|
bordered
|
||||||
|
className={styles["filter-item"]}
|
||||||
|
onClick={selectAll}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
text={Locale.Select.Latest}
|
||||||
|
bordered
|
||||||
|
className={styles["filter-item"]}
|
||||||
|
onClick={() =>
|
||||||
|
props.updateSelection((selection) => {
|
||||||
|
selection.clear();
|
||||||
|
messages
|
||||||
|
.slice(messageCount - 10)
|
||||||
|
.forEach((m) => selection.add(m.id!));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
text={Locale.Select.Clear}
|
||||||
|
bordered
|
||||||
|
className={styles["filter-item"]}
|
||||||
|
onClick={() =>
|
||||||
|
props.updateSelection((selection) => selection.clear())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["messages"]}>
|
||||||
|
{messages.map((m, i) => {
|
||||||
|
if (!isInSearchResult(m.id!)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles["message"]} ${
|
||||||
|
props.selection.has(m.id!) && styles["message-selected"]
|
||||||
|
}`}
|
||||||
|
key={i}
|
||||||
|
onClick={() => {
|
||||||
|
props.updateSelection((selection) => {
|
||||||
|
const id = m.id ?? i;
|
||||||
|
selection.has(id) ? selection.delete(id) : selection.add(id);
|
||||||
|
});
|
||||||
|
onClickIndex(i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles["avatar"]}>
|
||||||
|
{m.role === "user" ? (
|
||||||
|
<Avatar avatar={config.avatar}></Avatar>
|
||||||
|
) : (
|
||||||
|
<MaskAvatar mask={session.mask} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles["body"]}>
|
||||||
|
<div className={styles["date"]}>
|
||||||
|
{new Date(m.date).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className={`${styles["content"]} one-line`}>
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -36,11 +36,30 @@ const cn = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Export: {
|
Export: {
|
||||||
Title: "导出聊天记录为 Markdown",
|
Title: "分享聊天记录",
|
||||||
Copy: "全部复制",
|
Copy: "全部复制",
|
||||||
Download: "下载文件",
|
Download: "下载文件",
|
||||||
|
Share: "分享到 ShareGPT",
|
||||||
MessageFromYou: "来自你的消息",
|
MessageFromYou: "来自你的消息",
|
||||||
MessageFromChatGPT: "来自 ChatGPT 的消息",
|
MessageFromChatGPT: "来自 ChatGPT 的消息",
|
||||||
|
Format: {
|
||||||
|
Title: "导出格式",
|
||||||
|
SubTitle: "可以导出 Markdown 文本或者 PNG 图片",
|
||||||
|
},
|
||||||
|
IncludeContext: {
|
||||||
|
Title: "包含面具上下文",
|
||||||
|
SubTitle: "是否在消息中展示面具上下文",
|
||||||
|
},
|
||||||
|
Steps: {
|
||||||
|
Select: "选取",
|
||||||
|
Preview: "预览",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
Search: "搜索消息",
|
||||||
|
All: "选取全部",
|
||||||
|
Latest: "最近十条",
|
||||||
|
Clear: "清除选中",
|
||||||
},
|
},
|
||||||
Memory: {
|
Memory: {
|
||||||
Title: "历史摘要",
|
Title: "历史摘要",
|
||||||
|
@ -37,11 +37,30 @@ const en: RequiredLocaleType = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Export: {
|
Export: {
|
||||||
Title: "All Messages",
|
Title: "Export Messages",
|
||||||
Copy: "Copy All",
|
Copy: "Copy All",
|
||||||
Download: "Download",
|
Download: "Download",
|
||||||
MessageFromYou: "Message From You",
|
MessageFromYou: "Message From You",
|
||||||
MessageFromChatGPT: "Message From ChatGPT",
|
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: {
|
Memory: {
|
||||||
Title: "Memory Prompt",
|
Title: "Memory Prompt",
|
||||||
|
@ -13,12 +13,13 @@
|
|||||||
"proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev"
|
"proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^16.2.0",
|
|
||||||
"@fortaine/fetch-event-source": "^3.0.6",
|
"@fortaine/fetch-event-source": "^3.0.6",
|
||||||
|
"@hello-pangea/dnd": "^16.2.0",
|
||||||
"@svgr/webpack": "^6.5.1",
|
"@svgr/webpack": "^6.5.1",
|
||||||
"@vercel/analytics": "^0.1.11",
|
"@vercel/analytics": "^0.1.11",
|
||||||
"emoji-picker-react": "^4.4.7",
|
"emoji-picker-react": "^4.4.7",
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^6.6.2",
|
||||||
|
"html-to-image": "^1.11.11",
|
||||||
"mermaid": "^10.1.0",
|
"mermaid": "^10.1.0",
|
||||||
"next": "^13.4.3",
|
"next": "^13.4.3",
|
||||||
"node-fetch": "^3.3.1",
|
"node-fetch": "^3.3.1",
|
||||||
|
@ -3215,6 +3215,11 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react-is "^16.7.0"
|
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:
|
human-signals@^4.3.0:
|
||||||
version "4.3.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2"
|
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2"
|
||||||
|
Loading…
Reference in New Issue
Block a user