mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-11-04 08:13:43 +08:00 
			
		
		
		
	Merge pull request #1678 from Yidadaa/export
feat: close #580 export messages as image
This commit is contained in:
		@@ -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: () => <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 }) {
 | 
			
		||||
  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<HTMLTextAreaElement>(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);
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -917,6 +878,10 @@ export function Chat() {
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {showExport && (
 | 
			
		||||
        <ExportMessageModal onClose={() => setShowExport(false)} />
 | 
			
		||||
      )}
 | 
			
		||||
    </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 {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
 
 | 
			
		||||
@@ -121,7 +121,7 @@ export function Markdown(
 | 
			
		||||
    content: string;
 | 
			
		||||
    loading?: boolean;
 | 
			
		||||
    fontSize?: number;
 | 
			
		||||
    parentRef: RefObject<HTMLDivElement>;
 | 
			
		||||
    parentRef?: RefObject<HTMLDivElement>;
 | 
			
		||||
    defaultShow?: boolean;
 | 
			
		||||
  } & React.DOMAttributes<HTMLDivElement>,
 | 
			
		||||
) {
 | 
			
		||||
@@ -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 = () => {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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: {
 | 
			
		||||
    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: "历史摘要",
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user